* 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>
164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getPool } from '@/tests/pg/setup'
|
|
import { seedCompany } from '@/tests/pg/fixtures'
|
|
|
|
describe('commit_asset_disposal (pg-real)', () => {
|
|
async function insertAsset(userId: string, companyId: string): Promise<string> {
|
|
const assetId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.assets (
|
|
id, user_id, company_id, name, category, acquisition_date,
|
|
acquisition_cost, salvage_value, useful_life_months,
|
|
depreciation_method, bas_asset_account, bas_accumulated_account,
|
|
bas_expense_account
|
|
) VALUES ($1, $2, $3, 'Machine', 'equipment', '2025-01-01',
|
|
100000, 0, 60, 'linear', '1220', '1229', '7832')`,
|
|
[assetId, userId, companyId],
|
|
)
|
|
return assetId
|
|
}
|
|
|
|
async function insertDraft(args: {
|
|
userId: string
|
|
companyId: string
|
|
fiscalPeriodId: string
|
|
debitLines: Array<[string, number]>
|
|
creditLines: Array<[string, number]>
|
|
}): Promise<string> {
|
|
const entryId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.journal_entries (
|
|
id, user_id, company_id, fiscal_period_id, voucher_number,
|
|
voucher_series, entry_date, description, source_type, status
|
|
) VALUES ($1, $2, $3, $4, 0, 'A', '2026-06-30',
|
|
'Asset disposal', 'system', 'draft')`,
|
|
[entryId, args.userId, args.companyId, args.fiscalPeriodId],
|
|
)
|
|
for (const [account, amount] of args.debitLines) {
|
|
await getPool().query(
|
|
`INSERT INTO public.journal_entry_lines
|
|
(journal_entry_id, account_number, debit_amount, credit_amount)
|
|
VALUES ($1, $2, $3, 0)`,
|
|
[entryId, account, amount],
|
|
)
|
|
}
|
|
for (const [account, amount] of args.creditLines) {
|
|
await getPool().query(
|
|
`INSERT INTO public.journal_entry_lines
|
|
(journal_entry_id, account_number, debit_amount, credit_amount)
|
|
VALUES ($1, $2, 0, $3)`,
|
|
[entryId, account, amount],
|
|
)
|
|
}
|
|
return entryId
|
|
}
|
|
|
|
async function commit(args: {
|
|
companyId: string
|
|
assetId: string
|
|
entryId: string
|
|
fiscalPeriodId: string
|
|
disposalType?: string
|
|
currentDepreciation?: number
|
|
}) {
|
|
return getPool().query(
|
|
`SELECT * FROM public.commit_asset_disposal(
|
|
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5::text,
|
|
'2026-06-30'::date, 80000::numeric, 0::numeric, 'exempt'::text,
|
|
$6::numeric, 0::numeric, 'none'::text, 4::integer, 5::integer,
|
|
0::numeric, 0::numeric, 0::numeric, NULL::text, NULL::text
|
|
)`,
|
|
[
|
|
args.companyId,
|
|
args.assetId,
|
|
args.entryId,
|
|
args.fiscalPeriodId,
|
|
args.disposalType ?? 'sale',
|
|
args.currentDepreciation ?? 0,
|
|
],
|
|
)
|
|
}
|
|
|
|
it('posts the voucher, schedule, and register state in one transaction', async () => {
|
|
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
|
const assetId = await insertAsset(userId, companyId)
|
|
const entryId = await insertDraft({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
debitLines: [['7832', 10_000], ['1229', 30_000], ['1930', 80_000]],
|
|
creditLines: [['1229', 10_000], ['1220', 100_000], ['3973', 10_000]],
|
|
})
|
|
|
|
await commit({ companyId, assetId, entryId, fiscalPeriodId, currentDepreciation: 10_000 })
|
|
|
|
const entry = await getPool().query(
|
|
`SELECT status, voucher_number FROM public.journal_entries WHERE id = $1`,
|
|
[entryId],
|
|
)
|
|
const asset = await getPool().query(
|
|
`SELECT disposed_at::text, disposal_type, disposal_journal_entry_id
|
|
FROM public.assets WHERE id = $1`,
|
|
[assetId],
|
|
)
|
|
const schedule = await getPool().query(
|
|
`SELECT planned_depreciation::numeric, journal_entry_id
|
|
FROM public.depreciation_schedules
|
|
WHERE asset_id = $1 AND fiscal_period_id = $2`,
|
|
[assetId, fiscalPeriodId],
|
|
)
|
|
|
|
expect(entry.rows[0]).toMatchObject({ status: 'posted' })
|
|
expect(entry.rows[0].voucher_number).toBeGreaterThan(0)
|
|
expect(asset.rows[0]).toMatchObject({
|
|
disposed_at: '2026-06-30',
|
|
disposal_type: 'sale',
|
|
disposal_journal_entry_id: entryId,
|
|
})
|
|
expect(Number(schedule.rows[0].planned_depreciation)).toBe(10_000)
|
|
expect(schedule.rows[0].journal_entry_id).toBe(entryId)
|
|
|
|
await expect(
|
|
getPool().query(`UPDATE public.assets SET disposed_proceeds = 1 WHERE id = $1`, [assetId]),
|
|
).rejects.toThrow(/Cannot modify financial or disposal attributes/)
|
|
|
|
await expect(
|
|
getPool().query(`UPDATE public.assets SET notes = 'Audit note' WHERE id = $1`, [assetId]),
|
|
).resolves.toBeDefined()
|
|
})
|
|
|
|
it('rolls the voucher commit back when the register update fails', async () => {
|
|
const { userId, companyId, fiscalPeriodId } = await seedCompany()
|
|
const assetId = await insertAsset(userId, companyId)
|
|
const entryId = await insertDraft({
|
|
userId,
|
|
companyId,
|
|
fiscalPeriodId,
|
|
debitLines: [['1930', 80_000], ['7973', 20_000]],
|
|
creditLines: [['1220', 100_000]],
|
|
})
|
|
|
|
await expect(
|
|
commit({
|
|
companyId,
|
|
assetId,
|
|
entryId,
|
|
fiscalPeriodId,
|
|
disposalType: 'invalid',
|
|
}),
|
|
).rejects.toThrow()
|
|
|
|
const entry = await getPool().query(
|
|
`SELECT status, voucher_number FROM public.journal_entries WHERE id = $1`,
|
|
[entryId],
|
|
)
|
|
const asset = await getPool().query(
|
|
`SELECT disposed_at FROM public.assets WHERE id = $1`,
|
|
[assetId],
|
|
)
|
|
expect(entry.rows[0]).toMatchObject({ status: 'draft', voucher_number: 0 })
|
|
expect(asset.rows[0].disposed_at).toBeNull()
|
|
})
|
|
})
|