* feat: add safe company migration reset * fix: harden company reset eligibility * fix: close company reset compliance gaps * test: fix migration reset pg-real probes * fix: preserve migration archive access * docs: explain migration numbering continuity * fix: block reset with VAT workflow state * fix: block externally staged reset data * fix: address migration reset review findings * fix: clear stale migration archive estimate * fix: retry migration archive estimates
241 lines
7.2 KiB
PL/PgSQL
241 lines
7.2 KiB
PL/PgSQL
-- Close the remaining archive-and-replace boundaries found in compliance
|
|
-- review. Existing invoice records are accounting documents even before a
|
|
-- voucher is posted, and retained filing/import evidence must stay static.
|
|
|
|
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
|
|
RENAME TO company_migration_reset_snapshot_before_20260818143004;
|
|
|
|
CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_snapshot jsonb;
|
|
v_blockers jsonb;
|
|
v_invoice_records integer;
|
|
BEGIN
|
|
v_snapshot := public.company_migration_reset_snapshot_before_20260818143004(
|
|
p_company_id
|
|
);
|
|
|
|
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
|
|
RETURN v_snapshot;
|
|
END IF;
|
|
|
|
v_invoice_records :=
|
|
COALESCE((v_snapshot -> 'counts' ->> 'invoices')::integer, 0)
|
|
+ COALESCE((v_snapshot -> 'counts' ->> 'supplier_invoices')::integer, 0);
|
|
|
|
SELECT COALESCE(jsonb_agg(existing.blocker ORDER BY existing.position), '[]'::jsonb)
|
|
INTO v_blockers
|
|
FROM jsonb_array_elements(v_snapshot -> 'blockers')
|
|
WITH ORDINALITY AS existing(blocker, position)
|
|
WHERE existing.blocker ->> 'code' <> 'invoice_records_exist';
|
|
|
|
IF v_invoice_records > 0 THEN
|
|
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
|
'code', 'invoice_records_exist',
|
|
'count', v_invoice_records
|
|
));
|
|
END IF;
|
|
|
|
RETURN v_snapshot || jsonb_build_object(
|
|
'eligible', jsonb_array_length(v_blockers) = 0,
|
|
'blockers', v_blockers
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS
|
|
'Internal fail-closed reset snapshot. Any journal, voucher sequence, or invoice record blocks archive-and-replace.';
|
|
|
|
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818143004(uuid)
|
|
FROM PUBLIC, anon, authenticated;
|
|
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
|
|
FROM PUBLIC, anon, authenticated;
|
|
|
|
-- Check both sides of an UPDATE. The first version checked only OLD, which
|
|
-- blocked edits to an archived row but did not independently reject moving an
|
|
-- active row into an archived company through an elevated writer.
|
|
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_mutation()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_old_company_id uuid;
|
|
v_new_company_id uuid;
|
|
BEGIN
|
|
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
|
v_old_company_id := OLD.company_id;
|
|
END IF;
|
|
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
|
v_new_company_id := NEW.company_id;
|
|
END IF;
|
|
|
|
IF EXISTS (
|
|
SELECT 1
|
|
FROM public.company_migration_resets
|
|
WHERE source_company_id IN (v_old_company_id, v_new_company_id)
|
|
) THEN
|
|
RAISE EXCEPTION 'Archived migration reset source records are immutable';
|
|
END IF;
|
|
|
|
IF TG_OP = 'DELETE' THEN
|
|
RETURN OLD;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.block_migration_reset_source_mutation()
|
|
FROM PUBLIC, anon, authenticated;
|
|
|
|
-- These tables can still contain draft or disconnected evidence on an
|
|
-- otherwise eligible source. Once archived, all such rows must be as static as
|
|
-- transactions and documents. The existing trigger function checks the reset
|
|
-- audit row and does not affect active companies.
|
|
DO $$
|
|
DECLARE
|
|
v_table text;
|
|
BEGIN
|
|
FOREACH v_table IN ARRAY ARRAY[
|
|
'bank_connections',
|
|
'company_settings',
|
|
'salary_runs',
|
|
'salary_run_employees',
|
|
'salary_line_items',
|
|
'agi_declarations',
|
|
'arsredovisning_submissions',
|
|
'rot_rut_payout_requests',
|
|
'skatteverket_api_audit_log'
|
|
]
|
|
LOOP
|
|
EXECUTE format(
|
|
'CREATE TRIGGER %I BEFORE INSERT OR UPDATE OR DELETE ON public.%I '
|
|
'FOR EACH ROW EXECUTE FUNCTION public.block_migration_reset_source_mutation()',
|
|
v_table || '_block_migration_reset_source_mutation',
|
|
v_table
|
|
);
|
|
END LOOP;
|
|
END;
|
|
$$;
|
|
|
|
-- Company identity and archive metadata are part of the retained system
|
|
-- documentation. The reset transaction finishes all source-company updates
|
|
-- before inserting the audit link, so later source updates are never needed.
|
|
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_company_mutation()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1
|
|
FROM public.company_migration_resets
|
|
WHERE source_company_id = OLD.id
|
|
) THEN
|
|
RAISE EXCEPTION 'Archived migration reset source company is immutable';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER zz_companies_block_migration_reset_source_mutation
|
|
BEFORE UPDATE ON public.companies
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION public.block_migration_reset_source_company_mutation();
|
|
|
|
REVOKE ALL ON FUNCTION public.block_migration_reset_source_company_mutation()
|
|
FROM PUBLIC, anon, authenticated;
|
|
|
|
-- Journal lines are company-scoped through their parent. Check both parent
|
|
-- references on UPDATE so a line cannot be reassigned into a retained source.
|
|
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_journal_line_mutation()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_old_entry_id uuid;
|
|
v_new_entry_id uuid;
|
|
BEGIN
|
|
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
|
v_old_entry_id := OLD.journal_entry_id;
|
|
END IF;
|
|
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
|
v_new_entry_id := NEW.journal_entry_id;
|
|
END IF;
|
|
|
|
IF EXISTS (
|
|
SELECT 1
|
|
FROM public.journal_entries entry
|
|
JOIN public.company_migration_resets reset
|
|
ON reset.source_company_id = entry.company_id
|
|
WHERE entry.id IN (v_old_entry_id, v_new_entry_id)
|
|
) THEN
|
|
RAISE EXCEPTION 'Archived migration reset source records are immutable';
|
|
END IF;
|
|
|
|
IF TG_OP = 'DELETE' THEN
|
|
RETURN OLD;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.block_migration_reset_source_journal_line_mutation()
|
|
FROM PUBLIC, anon, authenticated;
|
|
|
|
-- ROT/RUT request items are company-scoped through their parent and therefore
|
|
-- need the same parent lookup pattern as journal-entry lines.
|
|
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_rot_rut_item_mutation()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_old_request_id uuid;
|
|
v_new_request_id uuid;
|
|
BEGIN
|
|
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
|
v_old_request_id := OLD.request_id;
|
|
END IF;
|
|
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
|
v_new_request_id := NEW.request_id;
|
|
END IF;
|
|
|
|
IF EXISTS (
|
|
SELECT 1
|
|
FROM public.rot_rut_payout_requests request
|
|
JOIN public.company_migration_resets reset
|
|
ON reset.source_company_id = request.company_id
|
|
WHERE request.id IN (v_old_request_id, v_new_request_id)
|
|
) THEN
|
|
RAISE EXCEPTION 'Archived migration reset source records are immutable';
|
|
END IF;
|
|
|
|
IF TG_OP = 'DELETE' THEN
|
|
RETURN OLD;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER rot_rut_payout_request_items_block_migration_reset_source_mutation
|
|
BEFORE INSERT OR UPDATE OR DELETE ON public.rot_rut_payout_request_items
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION public.block_migration_reset_source_rot_rut_item_mutation();
|
|
|
|
REVOKE ALL ON FUNCTION public.block_migration_reset_source_rot_rut_item_mutation()
|
|
FROM PUBLIC, anon, authenticated;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|