feat: add safe owner-only migration reset (#1682)
* 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
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
-- Keep archive-and-replace resets before the first accounting record exists.
|
||||
-- A replacement represents the same legal entity, so it must not start a
|
||||
-- second voucher-number namespace after any draft, posting, or sequence state
|
||||
-- has already been created on the retained source.
|
||||
|
||||
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
RENAME TO company_migration_reset_snapshot_before_20260818141018;
|
||||
|
||||
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_journal_entries integer;
|
||||
v_voucher_sequences integer;
|
||||
v_authority_submissions integer;
|
||||
v_direct_vat_submissions integer;
|
||||
BEGIN
|
||||
v_snapshot := public.company_migration_reset_snapshot_before_20260818141018(
|
||||
p_company_id
|
||||
);
|
||||
|
||||
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
|
||||
RETURN v_snapshot;
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_journal_entries
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = p_company_id;
|
||||
|
||||
SELECT count(*) INTO v_voucher_sequences
|
||||
FROM public.voucher_sequences
|
||||
WHERE company_id = p_company_id;
|
||||
|
||||
SELECT COALESCE(sum((existing.blocker ->> 'count')::integer), 0)
|
||||
INTO v_authority_submissions
|
||||
FROM jsonb_array_elements(v_snapshot -> 'blockers') AS existing(blocker)
|
||||
WHERE existing.blocker ->> 'code' = 'authority_submission_detected';
|
||||
|
||||
-- The VAT submission chain normally records a successful declaration lock
|
||||
-- first. Count the direct submission endpoint as independent evidence too,
|
||||
-- so a historical or interrupted audit chain still fails closed.
|
||||
SELECT count(*) INTO v_direct_vat_submissions
|
||||
FROM public.skatteverket_api_audit_log
|
||||
WHERE company_id = p_company_id
|
||||
AND outcome = 'ok'
|
||||
AND endpoint = 'declaration/submit';
|
||||
|
||||
v_authority_submissions := v_authority_submissions + v_direct_vat_submissions;
|
||||
|
||||
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' NOT IN (
|
||||
'non_import_committed_entries',
|
||||
'authority_submission_detected'
|
||||
);
|
||||
|
||||
IF v_journal_entries > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'journal_entries_exist',
|
||||
'count', v_journal_entries
|
||||
));
|
||||
END IF;
|
||||
|
||||
IF v_voucher_sequences > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'voucher_sequence_state_exists',
|
||||
'count', v_voucher_sequences
|
||||
));
|
||||
END IF;
|
||||
|
||||
IF v_authority_submissions > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'authority_submission_detected',
|
||||
'count', v_authority_submissions
|
||||
));
|
||||
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 entry or voucher sequence blocks archive-and-replace.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818141018(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,240 @@
|
||||
-- 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';
|
||||
@@ -0,0 +1,147 @@
|
||||
-- A VAT draft locked for signing can be signed outside Accounted after the
|
||||
-- lock succeeds. Historical direct-lock requests persisted their state in
|
||||
-- extension_data but did not always create a Skatteverket audit row. Treat any
|
||||
-- persisted VAT workflow state as authority interaction evidence: a saved
|
||||
-- draft must be removed through the product before self-service reset, and a
|
||||
-- locked state must never be cleared merely to make the company eligible.
|
||||
|
||||
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
RENAME TO company_migration_reset_snapshot_before_20260818224000;
|
||||
|
||||
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_authority_submissions integer;
|
||||
v_unlogged_vat_workflows integer;
|
||||
BEGIN
|
||||
v_snapshot := public.company_migration_reset_snapshot_before_20260818224000(
|
||||
p_company_id
|
||||
);
|
||||
|
||||
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
|
||||
RETURN v_snapshot;
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(sum((existing.blocker ->> 'count')::integer), 0)
|
||||
INTO v_authority_submissions
|
||||
FROM jsonb_array_elements(v_snapshot -> 'blockers') AS existing(blocker)
|
||||
WHERE existing.blocker ->> 'code' = 'authority_submission_detected';
|
||||
|
||||
-- extension_data.value is a JSONB string for the Skatteverket extension,
|
||||
-- but the key itself is sufficient evidence that a VAT draft workflow was
|
||||
-- started. Avoid double-counting periods already represented by a successful
|
||||
-- lock/submit audit row while still failing closed for historical direct
|
||||
-- lock calls that predate complete audit coverage.
|
||||
SELECT count(*) INTO v_unlogged_vat_workflows
|
||||
FROM public.extension_data state
|
||||
WHERE state.company_id = p_company_id
|
||||
AND state.extension_id = 'skatteverket'
|
||||
AND state.key LIKE 'submission\_%' ESCAPE '\'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.skatteverket_api_audit_log audit
|
||||
WHERE audit.company_id = p_company_id
|
||||
AND audit.outcome = 'ok'
|
||||
AND audit.endpoint IN ('declaration/lock', 'declaration/submit')
|
||||
AND (
|
||||
audit.redovisningsperiod IS NULL
|
||||
OR audit.redovisningsperiod = substring(state.key FROM 12)
|
||||
)
|
||||
);
|
||||
|
||||
v_authority_submissions :=
|
||||
v_authority_submissions + v_unlogged_vat_workflows;
|
||||
|
||||
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' <> 'authority_submission_detected';
|
||||
|
||||
IF v_authority_submissions > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'authority_submission_detected',
|
||||
'count', v_authority_submissions
|
||||
));
|
||||
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. VAT extension workflow state is authority interaction evidence.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818224000(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
-- An eligible source has no VAT submission_* rows. This narrow guard closes
|
||||
-- an in-flight or elevated insert after the source is archived without making
|
||||
-- unrelated extension runtime state interfere with account anonymization.
|
||||
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_vat_state_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_old_company_id uuid;
|
||||
v_new_company_id uuid;
|
||||
v_old_is_vat_state boolean := false;
|
||||
v_new_is_vat_state boolean := false;
|
||||
BEGIN
|
||||
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
||||
v_old_company_id := OLD.company_id;
|
||||
v_old_is_vat_state :=
|
||||
OLD.extension_id = 'skatteverket'
|
||||
AND OLD.key LIKE 'submission\_%' ESCAPE '\';
|
||||
END IF;
|
||||
|
||||
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
||||
v_new_company_id := NEW.company_id;
|
||||
v_new_is_vat_state :=
|
||||
NEW.extension_id = 'skatteverket'
|
||||
AND NEW.key LIKE 'submission\_%' ESCAPE '\';
|
||||
END IF;
|
||||
|
||||
IF (v_old_is_vat_state AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets
|
||||
WHERE source_company_id = v_old_company_id
|
||||
))
|
||||
OR (v_new_is_vat_state AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets
|
||||
WHERE source_company_id = v_new_company_id
|
||||
)) THEN
|
||||
RAISE EXCEPTION 'Archived migration reset source VAT state is immutable';
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER extension_data_block_migration_reset_source_vat_state
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON public.extension_data
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.block_migration_reset_source_vat_state_mutation();
|
||||
|
||||
REVOKE ALL ON FUNCTION public.block_migration_reset_source_vat_state_mutation()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,194 @@
|
||||
-- AGI uploads and ROT/RUT payout files cross the boundary into an external
|
||||
-- signing flow before Accounted can observe a completed filing. The local
|
||||
-- state can therefore lag behind Skatteverket. Treat every such staging state
|
||||
-- as authority interaction evidence instead of assuming that a missing
|
||||
-- receipt or a locally generated status proves the filing never happened.
|
||||
|
||||
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
RENAME TO company_migration_reset_snapshot_before_20260818231500;
|
||||
|
||||
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_authority_submissions integer;
|
||||
v_external_staging_workflows integer;
|
||||
BEGIN
|
||||
v_snapshot := public.company_migration_reset_snapshot_before_20260818231500(
|
||||
p_company_id
|
||||
);
|
||||
|
||||
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
|
||||
RETURN v_snapshot;
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(sum((existing.blocker ->> 'count')::integer), 0)
|
||||
INTO v_authority_submissions
|
||||
FROM jsonb_array_elements(v_snapshot -> 'blockers') AS existing(blocker)
|
||||
WHERE existing.blocker ->> 'code' = 'authority_submission_detected';
|
||||
|
||||
-- An AGI underlag in Eget utrymme can be signed in Mina Sidor while the
|
||||
-- declaration still says pending_signature. The extension cache is also
|
||||
-- evidence because it is written before the declaration status update and
|
||||
-- can survive a partial application failure. Normalize the period so the
|
||||
-- declaration row and its cache count as one workflow.
|
||||
--
|
||||
-- A generated ROT/RUT payout file is returned for manual upload and signing.
|
||||
-- Accounted cannot prove that a locally generated or cancelled request was
|
||||
-- never submitted externally, so every request state is retention-protected.
|
||||
SELECT count(*)
|
||||
INTO v_external_staging_workflows
|
||||
FROM (
|
||||
SELECT
|
||||
'agi:' || declaration.period_year::text
|
||||
|| lpad(declaration.period_month::text, 2, '0') AS workflow
|
||||
FROM public.agi_declarations declaration
|
||||
WHERE declaration.company_id = p_company_id
|
||||
AND declaration.status = 'pending_signature'
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
'agi:' || regexp_replace(
|
||||
substring(state.key FROM char_length('agi_submission_') + 1),
|
||||
'[^0-9]',
|
||||
'',
|
||||
'g'
|
||||
) AS workflow
|
||||
FROM public.extension_data state
|
||||
WHERE state.company_id = p_company_id
|
||||
AND state.extension_id = 'skatteverket'
|
||||
AND state.key LIKE 'agi\_submission\_%' ESCAPE '\'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.agi_declarations declaration
|
||||
WHERE declaration.company_id = p_company_id
|
||||
AND declaration.period_year::text
|
||||
|| lpad(declaration.period_month::text, 2, '0')
|
||||
= regexp_replace(
|
||||
substring(state.key FROM char_length('agi_submission_') + 1),
|
||||
'[^0-9]',
|
||||
'',
|
||||
'g'
|
||||
)
|
||||
AND (
|
||||
declaration.submitted_at IS NOT NULL
|
||||
OR declaration.status IN ('submitted', 'accepted', 'rejected')
|
||||
)
|
||||
)
|
||||
|
||||
UNION
|
||||
|
||||
SELECT 'rot_rut:' || request.id::text AS workflow
|
||||
FROM public.rot_rut_payout_requests request
|
||||
WHERE request.company_id = p_company_id
|
||||
AND NOT (
|
||||
request.submitted_at IS NOT NULL
|
||||
OR request.status IN ('submitted', 'paid', 'partially_paid', 'rejected')
|
||||
)
|
||||
) staging;
|
||||
|
||||
v_authority_submissions :=
|
||||
v_authority_submissions + v_external_staging_workflows;
|
||||
|
||||
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' <> 'authority_submission_detected';
|
||||
|
||||
IF v_authority_submissions > 0 THEN
|
||||
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
|
||||
'code', 'authority_submission_detected',
|
||||
'count', v_authority_submissions
|
||||
));
|
||||
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. External filing staging state is authority interaction evidence.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818231500(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
-- The retained source already rejects writes to agi_declarations and
|
||||
-- rot_rut_payout_requests. Extend the narrow extension_data guard so an
|
||||
-- in-flight or elevated writer cannot attach a new AGI upload state after the
|
||||
-- company has been archived.
|
||||
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_vat_state_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_old_company_id uuid;
|
||||
v_new_company_id uuid;
|
||||
v_old_is_authority_state boolean := false;
|
||||
v_new_is_authority_state boolean := false;
|
||||
BEGIN
|
||||
IF TG_OP IN ('UPDATE', 'DELETE') THEN
|
||||
v_old_company_id := OLD.company_id;
|
||||
v_old_is_authority_state :=
|
||||
OLD.extension_id = 'skatteverket'
|
||||
AND (
|
||||
OLD.key LIKE 'submission\_%' ESCAPE '\'
|
||||
OR OLD.key LIKE 'agi\_submission\_%' ESCAPE '\'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF TG_OP IN ('INSERT', 'UPDATE') THEN
|
||||
v_new_company_id := NEW.company_id;
|
||||
v_new_is_authority_state :=
|
||||
NEW.extension_id = 'skatteverket'
|
||||
AND (
|
||||
NEW.key LIKE 'submission\_%' ESCAPE '\'
|
||||
OR NEW.key LIKE 'agi\_submission\_%' ESCAPE '\'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF (v_old_is_authority_state AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets
|
||||
WHERE source_company_id = v_old_company_id
|
||||
))
|
||||
OR (v_new_is_authority_state AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.company_migration_resets
|
||||
WHERE source_company_id = v_new_company_id
|
||||
)) THEN
|
||||
RAISE EXCEPTION 'Archived migration reset source authority workflow state is immutable';
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TRIGGER extension_data_block_migration_reset_source_vat_state
|
||||
ON public.extension_data
|
||||
RENAME TO extension_data_block_migration_reset_source_authority_state;
|
||||
|
||||
COMMENT ON FUNCTION public.block_migration_reset_source_vat_state_mutation() IS
|
||||
'Rejects VAT and AGI authority workflow state on a retained migration reset source.';
|
||||
|
||||
REVOKE ALL ON FUNCTION public.block_migration_reset_source_vat_state_mutation()
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user