* 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>
344 lines
13 KiB
PL/PgSQL
344 lines
13 KiB
PL/PgSQL
-- 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';
|