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:
Mattsson
2026-08-04 09:59:52 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1c9d378df8
commit cb3ef45f14
22 changed files with 19644 additions and 1805 deletions
+120
View File
@@ -29,11 +29,14 @@ import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
import { getActor } from '@/lib/bookkeeping/actor-context'
import type {
AssetDisposalType,
AssetJamkningDirection,
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
JournalEntryLine,
JournalEntrySourceType,
VatTreatment,
} from '@/types'
const log = createLogger('bookkeeping.engine')
@@ -669,6 +672,123 @@ export async function commitEntry(
return result
}
export interface CommitAssetDisposalInput {
asset_id: string
fiscal_period_id: string
disposal_type: AssetDisposalType
disposed_at: string
disposed_proceeds: number
proceeds_vat: number
vat_treatment: VatTreatment | null
current_depreciation: number
jamkning_amount: number
jamkning_direction: AssetJamkningDirection
jamkning_remaining_years: number | null
jamkning_total_years: number | null
jamkning_original_input_vat: number | null
jamkning_original_deduction_percent: number | null
jamkning_new_deduction_percent: number | null
}
/**
* Commit a prepared asset-disposal draft and update the asset register in the
* same database transaction. The dedicated RPC delegates voucher numbering to
* commit_journal_entry, so disposal cannot leave a posted voucher without the
* corresponding immutable register state.
*/
export async function commitAssetDisposal(
supabase: SupabaseClient,
companyId: string,
userId: string,
entryId: string | null,
input: CommitAssetDisposalInput,
): Promise<JournalEntry | null> {
const actor = getActor()
const { error } = await supabase.rpc('commit_asset_disposal', {
p_company_id: companyId,
p_asset_id: input.asset_id,
p_entry_id: entryId,
p_fiscal_period_id: input.fiscal_period_id,
p_disposal_type: input.disposal_type,
p_disposed_at: input.disposed_at,
p_disposed_proceeds: input.disposed_proceeds,
p_proceeds_vat: input.proceeds_vat,
p_vat_treatment: input.vat_treatment,
p_current_depreciation: input.current_depreciation,
p_jamkning_amount: input.jamkning_amount,
p_jamkning_direction: input.jamkning_direction,
p_jamkning_remaining_years: input.jamkning_remaining_years,
p_jamkning_total_years: input.jamkning_total_years,
p_jamkning_original_input_vat: input.jamkning_original_input_vat,
p_jamkning_original_deduction_percent: input.jamkning_original_deduction_percent,
p_jamkning_new_deduction_percent: input.jamkning_new_deduction_percent,
p_actor_type: actor?.type ?? null,
p_actor_label: actor?.label ?? null,
})
if (error) {
log.error('commit_asset_disposal RPC failed', error, {
operation: 'commit_asset_disposal',
companyId,
userId,
entityType: 'asset',
entityId: input.asset_id,
journalEntryId: entryId,
pgCode: (error as { code?: string }).code,
})
throw new BookkeepingDatabaseError('commit_asset_disposal', error.message)
}
if (!entryId) return null
// The RPC has already committed the voucher and the register update at this
// point. A transient reload failure must not masquerade as a failed
// disposal, so retry once and log the divergence before surfacing it.
let completeEntry: JournalEntry | null = null
let lastFetchError: { message: string } | null = null
for (let attempt = 0; attempt < 2 && !completeEntry; attempt++) {
const { data, error: fetchError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', entryId)
.eq('company_id', companyId)
.single()
if (data && !fetchError) {
completeEntry = data as JournalEntry
} else {
lastFetchError = fetchError ?? { message: 'posted entry not found' }
}
}
if (!completeEntry) {
log.error(
'asset disposal committed but posted entry reload failed',
lastFetchError,
{
operation: 'commit_asset_disposal',
companyId,
userId,
entityType: 'asset',
entityId: input.asset_id,
journalEntryId: entryId,
},
)
throw new BookkeepingDatabaseError(
'fetch_asset_disposal_entry',
`disposal voucher is committed but could not be reloaded: ${
lastFetchError?.message ?? 'posted entry not found'
}`,
)
}
const result = completeEntry
await eventBus.emit({
type: 'journal_entry.committed',
payload: { entry: result, userId, companyId },
})
return result
}
/**
* Create a journal entry with lines (verifikation)
* Convenience wrapper: creates draft + commits in one step.
+2
View File
@@ -289,6 +289,8 @@ export type BookkeepingOperation =
| 'create_draft_entry'
| 'create_entry_lines'
| 'commit_entry'
| 'commit_asset_disposal'
| 'fetch_asset_disposal_entry'
| 'create_reversal_entry'
| 'create_reversal_lines'
| 'post_reversal_entry'