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
+31
-12
@@ -396,15 +396,17 @@ describe('RLS: cross-company isolation', () => {
|
||||
// can't loosen them without us noticing.
|
||||
describe('assets: disposal VAT + jämkning constraints', () => {
|
||||
it('accepts a disposed_vat_treatment from the allowed enum', async () => {
|
||||
// Disposal attributes are written in the same UPDATE that transitions the
|
||||
// asset to disposed: once disposed_at is set, the post-disposal
|
||||
// immutability trigger (20260803226000) freezes them.
|
||||
const assetId = await insertAsset({
|
||||
userId: companyA.userId,
|
||||
companyId: companyA.companyId,
|
||||
disposedAt: '2025-12-31',
|
||||
disposedProceeds: 100_000,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.assets
|
||||
SET disposed_proceeds_vat = 20000, disposed_vat_treatment = 'standard_25'
|
||||
SET disposed_at = '2025-12-31', disposed_proceeds = 100000,
|
||||
disposed_proceeds_vat = 20000, disposed_vat_treatment = 'standard_25'
|
||||
WHERE id = $1`,
|
||||
[assetId],
|
||||
)
|
||||
@@ -420,12 +422,13 @@ describe('assets: disposal VAT + jämkning constraints', () => {
|
||||
const assetId = await insertAsset({
|
||||
userId: companyA.userId,
|
||||
companyId: companyA.companyId,
|
||||
disposedAt: '2025-12-31',
|
||||
disposedProceeds: 100_000,
|
||||
})
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.assets SET disposed_vat_treatment = 'reduced_999' WHERE id = $1`,
|
||||
`UPDATE public.assets
|
||||
SET disposed_at = '2025-12-31', disposed_proceeds = 100000,
|
||||
disposed_vat_treatment = 'reduced_999'
|
||||
WHERE id = $1`,
|
||||
[assetId],
|
||||
),
|
||||
).rejects.toThrow(/check/i)
|
||||
@@ -435,20 +438,36 @@ describe('assets: disposal VAT + jämkning constraints', () => {
|
||||
const assetId = await insertAsset({
|
||||
userId: companyA.userId,
|
||||
companyId: companyA.companyId,
|
||||
disposedAt: '2025-12-31',
|
||||
disposedProceeds: 100_000,
|
||||
})
|
||||
// Treatment NULL + VAT > 0 must violate the consistency CHECK.
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.assets
|
||||
SET disposed_proceeds_vat = 20000, disposed_vat_treatment = NULL
|
||||
SET disposed_at = '2025-12-31', disposed_proceeds = 100000,
|
||||
disposed_proceeds_vat = 20000, disposed_vat_treatment = NULL
|
||||
WHERE id = $1`,
|
||||
[assetId],
|
||||
),
|
||||
).rejects.toThrow(/check|consistency/i)
|
||||
})
|
||||
|
||||
it('freezes disposal attributes once the asset is disposed', async () => {
|
||||
const assetId = await insertAsset({
|
||||
userId: companyA.userId,
|
||||
companyId: companyA.companyId,
|
||||
disposedAt: '2025-12-31',
|
||||
disposedProceeds: 100_000,
|
||||
})
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.assets
|
||||
SET disposed_proceeds_vat = 20000, disposed_vat_treatment = 'standard_25'
|
||||
WHERE id = $1`,
|
||||
[assetId],
|
||||
),
|
||||
).rejects.toThrow(/disposed asset/i)
|
||||
})
|
||||
|
||||
it('accepts zero VAT with null treatment (legacy / non-VAT disposal)', async () => {
|
||||
const assetId = await insertAsset({
|
||||
userId: companyA.userId,
|
||||
@@ -469,12 +488,12 @@ describe('assets: disposal VAT + jämkning constraints', () => {
|
||||
const assetId = await insertAsset({
|
||||
userId: companyA.userId,
|
||||
companyId: companyA.companyId,
|
||||
disposedAt: '2025-12-31',
|
||||
disposedProceeds: 60_000,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.assets
|
||||
SET jamkning_amount = 8000,
|
||||
SET disposed_at = '2025-12-31',
|
||||
disposed_proceeds = 60000,
|
||||
jamkning_amount = 8000,
|
||||
jamkning_remaining_months = 24,
|
||||
jamkning_total_months = 60,
|
||||
jamkning_original_input_vat = 20000
|
||||
|
||||
Reference in New Issue
Block a user