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
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user