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
@@ -0,0 +1,343 @@
|
||||
-- Atomic fixed-asset disposal.
|
||||
--
|
||||
-- A disposal voucher and the immutable asset-register update are one legal
|
||||
-- event. This RPC calls commit_journal_entry for sequential voucher numbering,
|
||||
-- records any disposal-date depreciation schedule, and marks the asset as
|
||||
-- disposed in the same transaction.
|
||||
|
||||
ALTER TABLE public.assets
|
||||
ADD COLUMN IF NOT EXISTS disposal_type text,
|
||||
ADD COLUMN IF NOT EXISTS disposal_journal_entry_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS jamkning_direction text,
|
||||
ADD COLUMN IF NOT EXISTS jamkning_remaining_years integer,
|
||||
ADD COLUMN IF NOT EXISTS jamkning_total_years integer,
|
||||
ADD COLUMN IF NOT EXISTS jamkning_original_deduction_percent numeric(5, 2),
|
||||
ADD COLUMN IF NOT EXISTS jamkning_new_deduction_percent numeric(5, 2);
|
||||
|
||||
-- All constrained columns are new and NULL for existing rows, so validation
|
||||
-- can never fail. Add every constraint NOT VALID and validate separately:
|
||||
-- an immediate FK validation takes SHARE ROW EXCLUSIVE on journal_entries (a
|
||||
-- hot table) and each plain CHECK scans assets under a blocking lock, while
|
||||
-- VALIDATE CONSTRAINT only needs SHARE UPDATE EXCLUSIVE and does not block
|
||||
-- writes.
|
||||
ALTER TABLE public.assets
|
||||
DROP CONSTRAINT IF EXISTS assets_disposal_journal_entry_id_fkey,
|
||||
ADD CONSTRAINT assets_disposal_journal_entry_id_fkey
|
||||
FOREIGN KEY (disposal_journal_entry_id)
|
||||
REFERENCES public.journal_entries(id) ON DELETE RESTRICT
|
||||
NOT VALID,
|
||||
DROP CONSTRAINT IF EXISTS assets_disposal_type_check,
|
||||
ADD CONSTRAINT assets_disposal_type_check CHECK (
|
||||
disposal_type IS NULL OR disposal_type IN ('sale', 'scrap', 'business_transfer')
|
||||
) NOT VALID,
|
||||
DROP CONSTRAINT IF EXISTS assets_jamkning_direction_check,
|
||||
ADD CONSTRAINT assets_jamkning_direction_check CHECK (
|
||||
jamkning_direction IS NULL OR jamkning_direction IN ('increase', 'decrease', 'none', 'transferred')
|
||||
) NOT VALID,
|
||||
DROP CONSTRAINT IF EXISTS assets_jamkning_years_check,
|
||||
ADD CONSTRAINT assets_jamkning_years_check CHECK (
|
||||
(jamkning_remaining_years IS NULL OR jamkning_remaining_years >= 0)
|
||||
AND (jamkning_total_years IS NULL OR jamkning_total_years IN (5, 10))
|
||||
AND (
|
||||
jamkning_remaining_years IS NULL
|
||||
OR jamkning_total_years IS NULL
|
||||
OR jamkning_remaining_years <= jamkning_total_years
|
||||
)
|
||||
) NOT VALID,
|
||||
DROP CONSTRAINT IF EXISTS assets_jamkning_percent_check,
|
||||
ADD CONSTRAINT assets_jamkning_percent_check CHECK (
|
||||
(jamkning_original_deduction_percent IS NULL OR jamkning_original_deduction_percent BETWEEN 0 AND 100)
|
||||
AND (jamkning_new_deduction_percent IS NULL OR jamkning_new_deduction_percent BETWEEN 0 AND 100)
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE public.assets VALIDATE CONSTRAINT assets_disposal_journal_entry_id_fkey;
|
||||
ALTER TABLE public.assets VALIDATE CONSTRAINT assets_disposal_type_check;
|
||||
ALTER TABLE public.assets VALIDATE CONSTRAINT assets_jamkning_direction_check;
|
||||
ALTER TABLE public.assets VALIDATE CONSTRAINT assets_jamkning_years_check;
|
||||
ALTER TABLE public.assets VALIDATE CONSTRAINT assets_jamkning_percent_check;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.enforce_asset_post_disposal_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF OLD.disposed_at IS NOT NULL THEN
|
||||
IF NEW.category IS DISTINCT FROM OLD.category
|
||||
OR NEW.acquisition_cost IS DISTINCT FROM OLD.acquisition_cost
|
||||
OR NEW.salvage_value IS DISTINCT FROM OLD.salvage_value
|
||||
OR NEW.useful_life_months IS DISTINCT FROM OLD.useful_life_months
|
||||
OR NEW.depreciation_method IS DISTINCT FROM OLD.depreciation_method
|
||||
OR NEW.restvarde_target IS DISTINCT FROM OLD.restvarde_target
|
||||
OR NEW.bas_asset_account IS DISTINCT FROM OLD.bas_asset_account
|
||||
OR NEW.bas_accumulated_account IS DISTINCT FROM OLD.bas_accumulated_account
|
||||
OR NEW.bas_expense_account IS DISTINCT FROM OLD.bas_expense_account
|
||||
OR NEW.acquisition_date IS DISTINCT FROM OLD.acquisition_date
|
||||
OR NEW.k3_components IS DISTINCT FROM OLD.k3_components
|
||||
OR NEW.disposed_at IS DISTINCT FROM OLD.disposed_at
|
||||
OR NEW.disposed_proceeds IS DISTINCT FROM OLD.disposed_proceeds
|
||||
OR NEW.disposed_proceeds_vat IS DISTINCT FROM OLD.disposed_proceeds_vat
|
||||
OR NEW.disposed_vat_treatment IS DISTINCT FROM OLD.disposed_vat_treatment
|
||||
OR NEW.disposal_type IS DISTINCT FROM OLD.disposal_type
|
||||
OR NEW.disposal_journal_entry_id IS DISTINCT FROM OLD.disposal_journal_entry_id
|
||||
OR NEW.jamkning_amount IS DISTINCT FROM OLD.jamkning_amount
|
||||
OR NEW.jamkning_remaining_months IS DISTINCT FROM OLD.jamkning_remaining_months
|
||||
OR NEW.jamkning_total_months IS DISTINCT FROM OLD.jamkning_total_months
|
||||
OR NEW.jamkning_original_input_vat IS DISTINCT FROM OLD.jamkning_original_input_vat
|
||||
OR NEW.jamkning_direction IS DISTINCT FROM OLD.jamkning_direction
|
||||
OR NEW.jamkning_remaining_years IS DISTINCT FROM OLD.jamkning_remaining_years
|
||||
OR NEW.jamkning_total_years IS DISTINCT FROM OLD.jamkning_total_years
|
||||
OR NEW.jamkning_original_deduction_percent IS DISTINCT FROM OLD.jamkning_original_deduction_percent
|
||||
OR NEW.jamkning_new_deduction_percent IS DISTINCT FROM OLD.jamkning_new_deduction_percent THEN
|
||||
RAISE EXCEPTION 'Cannot modify financial or disposal attributes of a disposed asset (id=%)', OLD.id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.commit_asset_disposal(
|
||||
p_company_id uuid,
|
||||
p_asset_id uuid,
|
||||
p_entry_id uuid,
|
||||
p_fiscal_period_id uuid,
|
||||
p_disposal_type text,
|
||||
p_disposed_at date,
|
||||
p_disposed_proceeds numeric,
|
||||
p_proceeds_vat numeric,
|
||||
p_vat_treatment text,
|
||||
p_current_depreciation numeric,
|
||||
p_jamkning_amount numeric,
|
||||
p_jamkning_direction text,
|
||||
p_jamkning_remaining_years integer,
|
||||
p_jamkning_total_years integer,
|
||||
p_jamkning_original_input_vat numeric,
|
||||
p_jamkning_original_deduction_percent numeric,
|
||||
p_jamkning_new_deduction_percent numeric,
|
||||
p_actor_type text DEFAULT NULL,
|
||||
p_actor_label text DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE(voucher_number integer)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_asset_user_id uuid;
|
||||
v_entry_user_id uuid;
|
||||
v_schedule_id uuid;
|
||||
v_schedule_entry_id uuid;
|
||||
v_period_start date;
|
||||
v_period_closed boolean;
|
||||
v_period_locked_at timestamptz;
|
||||
v_company_lock_date date;
|
||||
v_voucher_number integer;
|
||||
v_jwt_role text := coalesce(
|
||||
nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
|
||||
''
|
||||
);
|
||||
BEGIN
|
||||
-- NULL-safe membership guard (20260703180000): never the raw
|
||||
-- "NOT IN (SELECT user_company_ids())" form, which is NULL-unsafe.
|
||||
IF v_jwt_role IN ('anon', 'authenticated')
|
||||
AND (
|
||||
NOT public.caller_is_company_member(p_company_id)
|
||||
OR NOT public.current_user_can_write()
|
||||
) THEN
|
||||
RAISE EXCEPTION 'unauthorized asset disposal for company %', p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
-- Disposal metadata invariants. The values are derived server-side by the
|
||||
-- same planner that builds the draft entry, but the RPC is independently
|
||||
-- callable, so reject internally inconsistent register metadata here.
|
||||
IF coalesce(p_disposed_proceeds, 0) < 0 OR coalesce(p_proceeds_vat, 0) < 0 THEN
|
||||
RAISE EXCEPTION 'Disposal proceeds and VAT must be non-negative'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF p_proceeds_vat > 0 AND p_vat_treatment IS NULL THEN
|
||||
RAISE EXCEPTION 'Disposal VAT requires a VAT treatment'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF p_proceeds_vat > p_disposed_proceeds THEN
|
||||
RAISE EXCEPTION 'Disposal VAT cannot exceed gross proceeds'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF p_disposal_type = 'scrap' AND coalesce(p_disposed_proceeds, 0) <> 0 THEN
|
||||
RAISE EXCEPTION 'Scrapping (utrangering) cannot carry proceeds'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
SELECT a.user_id
|
||||
INTO v_asset_user_id
|
||||
FROM public.assets a
|
||||
WHERE a.id = p_asset_id
|
||||
AND a.company_id = p_company_id
|
||||
AND a.disposed_at IS NULL
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Asset not found or already disposed: %', p_asset_id
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
SELECT fp.period_start, fp.is_closed, fp.locked_at
|
||||
INTO v_period_start, v_period_closed, v_period_locked_at
|
||||
FROM public.fiscal_periods fp
|
||||
WHERE fp.id = p_fiscal_period_id
|
||||
AND fp.company_id = p_company_id
|
||||
AND p_disposed_at BETWEEN fp.period_start AND fp.period_end;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Fiscal period does not contain disposal date'
|
||||
USING ERRCODE = '22007';
|
||||
END IF;
|
||||
|
||||
IF v_period_closed OR v_period_locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Cannot dispose asset in a locked or closed fiscal period'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
SELECT cs.bookkeeping_locked_through
|
||||
INTO v_company_lock_date
|
||||
FROM public.company_settings cs
|
||||
WHERE cs.company_id = p_company_id;
|
||||
|
||||
IF v_company_lock_date IS NOT NULL AND p_disposed_at <= v_company_lock_date THEN
|
||||
RAISE EXCEPTION 'Bookkeeping is locked through %', v_company_lock_date
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.depreciation_schedules ds
|
||||
JOIN public.fiscal_periods fp ON fp.id = ds.fiscal_period_id
|
||||
WHERE ds.company_id = p_company_id
|
||||
AND ds.asset_id = p_asset_id
|
||||
AND ds.journal_entry_id IS NOT NULL
|
||||
AND fp.period_start > v_period_start
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Later depreciation is already posted for asset %', p_asset_id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF p_entry_id IS NOT NULL THEN
|
||||
SELECT je.user_id
|
||||
INTO v_entry_user_id
|
||||
FROM public.journal_entries je
|
||||
WHERE je.id = p_entry_id
|
||||
AND je.company_id = p_company_id
|
||||
AND je.fiscal_period_id = p_fiscal_period_id
|
||||
AND je.entry_date = p_disposed_at
|
||||
AND je.status = 'draft'
|
||||
AND je.source_type = 'system'
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Valid disposal draft not found: %', p_entry_id
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
ELSIF abs(coalesce(p_current_depreciation, 0)) > 0.005 THEN
|
||||
RAISE EXCEPTION 'Current depreciation requires a disposal voucher'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF coalesce(p_current_depreciation, 0) > 0.005 THEN
|
||||
SELECT ds.id, ds.journal_entry_id
|
||||
INTO v_schedule_id, v_schedule_entry_id
|
||||
FROM public.depreciation_schedules ds
|
||||
WHERE ds.asset_id = p_asset_id
|
||||
AND ds.fiscal_period_id = p_fiscal_period_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF FOUND AND v_schedule_entry_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Depreciation was posted concurrently for asset %', p_asset_id
|
||||
USING ERRCODE = '23514';
|
||||
ELSIF FOUND THEN
|
||||
UPDATE public.depreciation_schedules
|
||||
SET planned_depreciation = p_current_depreciation,
|
||||
journal_entry_id = p_entry_id,
|
||||
posted_at = now()
|
||||
WHERE id = v_schedule_id;
|
||||
ELSE
|
||||
INSERT INTO public.depreciation_schedules (
|
||||
user_id,
|
||||
company_id,
|
||||
asset_id,
|
||||
fiscal_period_id,
|
||||
planned_depreciation,
|
||||
journal_entry_id,
|
||||
posted_at
|
||||
) VALUES (
|
||||
coalesce(v_entry_user_id, v_asset_user_id),
|
||||
p_company_id,
|
||||
p_asset_id,
|
||||
p_fiscal_period_id,
|
||||
p_current_depreciation,
|
||||
p_entry_id,
|
||||
now()
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF p_entry_id IS NOT NULL THEN
|
||||
SELECT committed.voucher_number
|
||||
INTO v_voucher_number
|
||||
-- commit_method must be one of journal_entries_commit_method_check's
|
||||
-- allowed values; the disposal dialog is a user-accepted commit.
|
||||
FROM public.commit_journal_entry(
|
||||
p_company_id,
|
||||
p_entry_id,
|
||||
'user_accept',
|
||||
NULL,
|
||||
p_actor_type,
|
||||
p_actor_label
|
||||
) AS committed;
|
||||
END IF;
|
||||
|
||||
UPDATE public.assets
|
||||
SET disposed_at = p_disposed_at,
|
||||
disposed_proceeds = p_disposed_proceeds,
|
||||
disposed_proceeds_vat = p_proceeds_vat,
|
||||
disposed_vat_treatment = p_vat_treatment,
|
||||
disposal_type = p_disposal_type,
|
||||
disposal_journal_entry_id = p_entry_id,
|
||||
jamkning_amount = p_jamkning_amount,
|
||||
jamkning_direction = p_jamkning_direction,
|
||||
jamkning_remaining_years = p_jamkning_remaining_years,
|
||||
jamkning_total_years = p_jamkning_total_years,
|
||||
jamkning_original_input_vat = p_jamkning_original_input_vat,
|
||||
jamkning_original_deduction_percent = p_jamkning_original_deduction_percent,
|
||||
jamkning_new_deduction_percent = p_jamkning_new_deduction_percent,
|
||||
jamkning_remaining_months = NULL,
|
||||
jamkning_total_months = NULL
|
||||
WHERE id = p_asset_id
|
||||
AND company_id = p_company_id
|
||||
AND disposed_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Asset disposal lost concurrent update: %', p_asset_id
|
||||
USING ERRCODE = '40001';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY SELECT v_voucher_number;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.commit_asset_disposal(
|
||||
uuid, uuid, uuid, uuid, text, date, numeric, numeric, text, numeric,
|
||||
numeric, text, integer, integer, numeric, numeric, numeric, text, text
|
||||
) FROM PUBLIC, anon;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.commit_asset_disposal(
|
||||
uuid, uuid, uuid, uuid, text, date, numeric, numeric, text, numeric,
|
||||
numeric, text, integer, integer, numeric, numeric, numeric, text, text
|
||||
) TO authenticated;
|
||||
|
||||
COMMENT ON FUNCTION public.commit_asset_disposal(
|
||||
uuid, uuid, uuid, uuid, text, date, numeric, numeric, text, numeric,
|
||||
numeric, text, integer, integer, numeric, numeric, numeric, text, text
|
||||
) IS 'Atomically posts a fixed-asset disposal voucher, disposal-date depreciation schedule, and immutable asset-register state.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user