Files
accounted/supabase/migrations/20260818084050_company_migration_reset.sql
T
Mattsson 3a1b842e4a 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
2026-08-19 12:04:24 +02:00

1017 lines
35 KiB
PL/PgSQL

-- Owner-only company reset for redoing a failed migration.
--
-- This is intentionally an archive-and-replace operation, not a delete. The
-- source company remains the retention container for every document, import,
-- transaction, fiscal period, voucher, and voucher sequence. A new active
-- company is created for the replacement migration. No enforcement trigger is
-- disabled and no accounting-shaped row is changed or removed.
CREATE TABLE public.company_migration_resets (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
source_company_id uuid NOT NULL UNIQUE REFERENCES public.companies(id) ON DELETE RESTRICT,
replacement_company_id uuid NOT NULL UNIQUE REFERENCES public.companies(id) ON DELETE RESTRICT,
actor_id uuid,
reason text NOT NULL CHECK (char_length(reason) BETWEEN 20 AND 1000),
confirmation_snapshot jsonb NOT NULL,
source_counts jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.company_migration_resets IS
'Append-only audit trail for owner-confirmed archive-and-replace migration resets. Source accounting records are retained unchanged.';
ALTER TABLE public.company_migration_resets ENABLE ROW LEVEL SECURITY;
CREATE POLICY company_migration_resets_select
ON public.company_migration_resets
FOR SELECT
USING (replacement_company_id IN (SELECT public.user_company_ids()));
CREATE INDEX idx_company_migration_resets_created_at
ON public.company_migration_resets (created_at DESC);
CREATE OR REPLACE FUNCTION public.company_migration_reset_audit_immutable()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RAISE EXCEPTION 'Company migration reset audit entries cannot be modified or deleted';
END;
$$;
CREATE TRIGGER company_migration_resets_no_update
BEFORE UPDATE ON public.company_migration_resets
FOR EACH ROW EXECUTE FUNCTION public.company_migration_reset_audit_immutable();
CREATE TRIGGER company_migration_resets_no_delete
BEFORE DELETE ON public.company_migration_resets
FOR EACH ROW EXECUTE FUNCTION public.company_migration_reset_audit_immutable();
CREATE TRIGGER company_migration_resets_no_truncate
BEFORE TRUNCATE ON public.company_migration_resets
FOR EACH STATEMENT EXECUTE FUNCTION public.company_migration_reset_audit_immutable();
REVOKE ALL ON FUNCTION public.company_migration_reset_audit_immutable() FROM PUBLIC, anon, authenticated;
REVOKE ALL ON TABLE public.company_migration_resets FROM PUBLIC, anon, authenticated;
GRANT SELECT ON TABLE public.company_migration_resets TO authenticated;
-- Close the invitation acceptance race after a successful reset. An insert
-- that started before the reset either completes before the source row lock
-- and is copied to the replacement, or resumes afterward and is rejected by
-- this trigger. This keeps the retained source closed to new memberships.
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_member_insert()
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 = NEW.company_id
) THEN
RAISE EXCEPTION 'Cannot add members to an archived migration reset source';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER company_members_block_migration_reset_source_insert
BEFORE INSERT ON public.company_members
FOR EACH ROW EXECUTE FUNCTION public.block_migration_reset_source_member_insert();
REVOKE ALL ON FUNCTION public.block_migration_reset_source_member_insert()
FROM PUBLIC, anon, authenticated;
-- Team membership sync must not target retained archives. Without this
-- active-company filter, one archived source row would make the trigger's
-- multi-row INSERT fail and prevent the same member reaching active companies.
CREATE OR REPLACE FUNCTION public.sync_team_member_to_companies()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_company_role text;
BEGIN
v_company_role := CASE
WHEN NEW.role IN ('owner', 'admin') THEN 'admin'
ELSE 'member'
END;
INSERT INTO public.company_members (company_id, user_id, role, source)
SELECT company.id, NEW.user_id, v_company_role, 'team'
FROM public.companies company
WHERE company.team_id = NEW.team_id
AND company.archived_at IS NULL
ON CONFLICT (company_id, user_id) DO NOTHING;
RETURN NEW;
END;
$$;
-- Once the reset audit row exists, the source becomes a write-closed
-- retention container. These guards also reject requests that passed an RLS
-- check before the reset, waited on the source-company lock, and resumed after
-- commit. Existing bookkeeping enforcement triggers remain fully enabled.
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_mutation()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_company_id uuid;
BEGIN
IF TG_OP = 'INSERT' THEN
v_company_id := NEW.company_id;
ELSE
v_company_id := OLD.company_id;
END IF;
IF EXISTS (
SELECT 1
FROM public.company_migration_resets
WHERE source_company_id = v_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;
$$;
DO $$
DECLARE
v_table text;
BEGIN
FOREACH v_table IN ARRAY ARRAY[
'fiscal_periods',
'journal_entries',
'transactions',
'document_attachments',
'voucher_sequences',
'sie_imports',
'bank_file_imports',
'skattekonto_file_imports',
'receipts',
'invoice_inbox_items',
'customers',
'suppliers',
'invoices',
'supplier_invoices',
'invoice_deliveries',
'stripe_payouts',
'stripe_payment_events',
'webshop_orders'
]
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;
$$;
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_entry_id uuid;
BEGIN
IF TG_OP = 'INSERT' THEN
v_entry_id := NEW.journal_entry_id;
ELSE
v_entry_id := OLD.journal_entry_id;
END IF;
IF EXISTS (
SELECT 1
FROM public.journal_entries je
JOIN public.company_migration_resets reset
ON reset.source_company_id = je.company_id
WHERE je.id = v_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;
$$;
CREATE TRIGGER journal_entry_lines_block_migration_reset_source_mutation
BEFORE INSERT OR UPDATE OR DELETE ON public.journal_entry_lines
FOR EACH ROW
EXECUTE FUNCTION public.block_migration_reset_source_journal_line_mutation();
CREATE OR REPLACE FUNCTION public.protect_migration_reset_source_archive()
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 'Migration reset source archive markers are immutable';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER companies_protect_migration_reset_source_archive
BEFORE UPDATE OF archived_at, archived_by ON public.companies
FOR EACH ROW EXECUTE FUNCTION public.protect_migration_reset_source_archive();
REVOKE ALL ON FUNCTION public.block_migration_reset_source_mutation()
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.block_migration_reset_source_journal_line_mutation()
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.protect_migration_reset_source_archive()
FROM PUBLIC, anon, authenticated;
-- Internal, fail-closed snapshot used by both preview and execution. Keeping
-- one implementation prevents the eligibility check from drifting between
-- the UI preview and the transaction that performs the reset.
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_company public.companies%ROWTYPE;
v_display_name text;
v_counts jsonb;
v_blockers jsonb := '[]'::jsonb;
v_locked_periods integer;
v_non_import_committed integer;
v_authority_submissions integer;
v_live_bank_connections integer;
v_active_automations integer;
v_incomplete_imports integer;
v_background_work integer;
BEGIN
SELECT * INTO v_company
FROM public.companies
WHERE id = p_company_id;
IF NOT FOUND THEN
RETURN jsonb_build_object(
'eligible', false,
'code', 'COMPANY_RESET_NOT_FOUND',
'blockers', jsonb_build_array(jsonb_build_object('code', 'company_not_found'))
);
END IF;
SELECT COALESCE(
NULLIF(trim(cs.company_name), ''),
NULLIF(trim(v_company.name), ''),
v_company.id::text
)
INTO v_display_name
FROM (SELECT 1) seed
LEFT JOIN public.company_settings cs ON cs.company_id = p_company_id;
SELECT jsonb_build_object(
'journal_entries', (SELECT count(*) FROM public.journal_entries WHERE company_id = p_company_id),
'journal_entry_lines', (
SELECT count(*)
FROM public.journal_entry_lines line
JOIN public.journal_entries entry ON entry.id = line.journal_entry_id
WHERE entry.company_id = p_company_id
),
'committed_import_entries', (
SELECT count(*)
FROM public.journal_entries
WHERE company_id = p_company_id
AND status IN ('posted', 'reversed')
AND source_type IN ('import', 'opening_balance')
),
'transactions', (SELECT count(*) FROM public.transactions WHERE company_id = p_company_id),
'fiscal_periods', (SELECT count(*) FROM public.fiscal_periods WHERE company_id = p_company_id),
'documents', (SELECT count(*) FROM public.document_attachments WHERE company_id = p_company_id),
'voucher_sequences', (SELECT count(*) FROM public.voucher_sequences WHERE company_id = p_company_id),
'sie_imports', (SELECT count(*) FROM public.sie_imports WHERE company_id = p_company_id),
'bank_file_imports', (SELECT count(*) FROM public.bank_file_imports WHERE company_id = p_company_id),
'skattekonto_file_imports', (SELECT count(*) FROM public.skattekonto_file_imports WHERE company_id = p_company_id),
'customers', (SELECT count(*) FROM public.customers WHERE company_id = p_company_id),
'suppliers', (SELECT count(*) FROM public.suppliers WHERE company_id = p_company_id),
'invoices', (SELECT count(*) FROM public.invoices WHERE company_id = p_company_id),
'supplier_invoices', (SELECT count(*) FROM public.supplier_invoices WHERE company_id = p_company_id),
'bank_connections', (SELECT count(*) FROM public.bank_connections WHERE company_id = p_company_id)
) INTO v_counts;
IF v_company.archived_at IS NOT NULL THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'company_already_archived',
'count', 1
));
END IF;
-- Self-service is a short migration-recovery window, not a general ledger
-- restart. Older companies require support and a case-specific review.
IF v_company.created_at < now() - interval '30 days' THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'migration_window_expired',
'count', 1
));
END IF;
-- Sandbox companies have a separate short-lived cleanup lifecycle and must
-- never be converted into retained legal archives by this product flow.
IF EXISTS (
SELECT 1
FROM public.company_settings
WHERE company_id = p_company_id
AND is_sandbox = true
) THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'sandbox_company',
'count', 1
));
END IF;
SELECT
(SELECT count(*)
FROM public.fiscal_periods
WHERE company_id = p_company_id
AND (is_closed = true OR locked_at IS NOT NULL OR closed_externally = true))
+
(SELECT count(*)
FROM public.company_settings
WHERE company_id = p_company_id
AND bookkeeping_locked_through IS NOT NULL)
INTO v_locked_periods;
IF v_locked_periods > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'locked_or_closed_periods',
'count', v_locked_periods
));
END IF;
-- A non-import committed entry proves that the company has moved beyond a
-- pure migration attempt. Reversals and corrections are non-import source
-- types and therefore block as well.
SELECT count(*) INTO v_non_import_committed
FROM public.journal_entries
WHERE company_id = p_company_id
AND status IN ('posted', 'reversed')
AND (source_type IS NULL OR source_type NOT IN ('import', 'opening_balance'));
IF v_non_import_committed > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'non_import_committed_entries',
'count', v_non_import_committed
));
END IF;
-- A live PSD2 connection can be selected by the service-role sync cron and
-- import transactions without an interactive company session. Require the
-- owner to disconnect it before reset so the retained source stays static.
SELECT count(*) INTO v_live_bank_connections
FROM public.bank_connections
WHERE company_id = p_company_id
AND status IN ('pending', 'pending_selection', 'active');
IF v_live_bank_connections > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'live_bank_connections',
'count', v_live_bank_connections
));
END IF;
-- Never archive while an import can still finalize rows against the source.
SELECT
(SELECT count(*) FROM public.sie_imports
WHERE company_id = p_company_id AND status IN ('pending', 'mapped'))
+
(SELECT count(*) FROM public.bank_file_imports
WHERE company_id = p_company_id AND status IN ('pending', 'processing'))
+
(SELECT count(*) FROM public.skattekonto_file_imports
WHERE company_id = p_company_id AND status IN ('pending', 'processing'))
INTO v_incomplete_imports;
IF v_incomplete_imports > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'imports_in_progress',
'count', v_incomplete_imports
));
END IF;
-- Other background writers can run on service credentials or team-level
-- capabilities after the interactive company session has moved. A reset is
-- allowed only after every such integration or schedule is inactive.
SELECT
(SELECT count(*) FROM public.stripe_connections
WHERE company_id = p_company_id AND status IN ('pending', 'active'))
+
(SELECT count(*) FROM public.woocommerce_connections
WHERE company_id = p_company_id AND status IN ('pending', 'active'))
+
(SELECT count(*) FROM public.shopify_connections
WHERE company_id = p_company_id AND status IN ('pending', 'active'))
+
(SELECT count(*) FROM public.skatteverket_tokens
WHERE company_id = p_company_id AND status = 'active')
+
(SELECT count(*) FROM public.skatteverket_company_connections
WHERE company_id = p_company_id AND status IN ('pending', 'partial', 'verified'))
+
(SELECT count(*) FROM public.recurring_invoice_schedules
WHERE company_id = p_company_id AND status = 'active')
+
(SELECT count(*) FROM public.accrual_schedule_installments
WHERE company_id = p_company_id AND status = 'pending')
INTO v_active_automations;
IF v_active_automations > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'active_integrations_or_schedules',
'count', v_active_automations
));
END IF;
-- Tenant-scoped workers must have reached a terminal state. The source
-- mutation guards above are the final backstop for a request that was
-- already waiting on a lock when the reset committed.
SELECT
(SELECT count(*) FROM public.invoice_inbox_items
WHERE company_id = p_company_id AND status = 'processing')
+
(SELECT count(*) FROM public.receipts
WHERE company_id = p_company_id AND status IN ('pending', 'processing'))
+
(SELECT count(*) FROM public.operations
WHERE company_id = p_company_id AND status IN ('queued', 'running'))
+
(SELECT count(*) FROM public.pending_operations
WHERE company_id = p_company_id AND status = 'committing')
+
(SELECT count(*) FROM public.invoice_deliveries
WHERE company_id = p_company_id AND status IN ('preparing', 'pending'))
+
(SELECT count(*) FROM public.stripe_payouts
WHERE company_id = p_company_id AND status = 'processing')
+
(SELECT count(*) FROM public.stripe_payment_events
WHERE company_id = p_company_id AND status = 'processing')
+
(SELECT count(*) FROM public.whatsapp_conversations
WHERE company_id = p_company_id
AND (pending_ack = true OR service_window_expires_at > now()))
+
(SELECT count(*)
FROM public.whatsapp_messages message
JOIN public.whatsapp_conversations conversation
ON conversation.id = message.conversation_id
WHERE conversation.company_id = p_company_id
AND message.processing_status IN ('received', 'processing'))
INTO v_background_work;
IF v_background_work > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'background_work_in_progress',
'count', v_background_work
));
END IF;
-- Known authority interactions are combined into one conservative blocker.
-- VAT declaration/lock is included because the final signature happens at
-- Skatteverket and cannot be observed reliably from this database.
SELECT
(SELECT count(*)
FROM public.agi_declarations
WHERE company_id = p_company_id
AND (submitted_at IS NOT NULL OR status IN ('submitted', 'accepted', 'rejected')))
+
(SELECT count(*)
FROM public.salary_runs
WHERE company_id = p_company_id
AND agi_submitted_at IS NOT NULL)
+
(SELECT count(*)
FROM public.arsredovisning_submissions
WHERE company_id = p_company_id
AND environment = 'prod'
AND (
uploaded_at IS NOT NULL
OR status IN (
'sending', 'uploaded', 'unknown', 'inkommen', 'forelagd',
'komplettering', 'registrerad', 'avslutad'
)
))
+
(SELECT count(*)
FROM public.rot_rut_payout_requests
WHERE company_id = p_company_id
AND (submitted_at IS NOT NULL OR status IN ('submitted', 'paid', 'partially_paid', 'rejected')))
+
(SELECT count(*)
FROM public.skatteverket_api_audit_log
WHERE company_id = p_company_id
AND outcome = 'ok'
AND endpoint IN ('agi/submit', 'declaration/lock'))
INTO v_authority_submissions;
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 jsonb_build_object(
'eligible', jsonb_array_length(v_blockers) = 0,
'company_id', p_company_id,
'display_name', v_display_name,
'created_at', v_company.created_at,
'window_ends_at', v_company.created_at + interval '30 days',
'counts', v_counts,
'blockers', v_blockers
);
END;
$$;
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid) FROM PUBLIC, anon, authenticated;
CREATE OR REPLACE FUNCTION public.get_company_migration_reset_eligibility(p_company_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_actor uuid := auth.uid();
v_role text;
BEGIN
IF v_actor IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_FORBIDDEN');
END IF;
SELECT role INTO v_role
FROM public.company_members
WHERE company_id = p_company_id
AND user_id = v_actor;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_NOT_FOUND');
END IF;
IF v_role <> 'owner' THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_FORBIDDEN');
END IF;
RETURN jsonb_build_object(
'ok', true,
'eligibility', public.company_migration_reset_snapshot(p_company_id)
);
END;
$$;
REVOKE ALL ON FUNCTION public.get_company_migration_reset_eligibility(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_company_migration_reset_eligibility(uuid) TO authenticated;
CREATE OR REPLACE FUNCTION public.reset_company_for_migration(
p_company_id uuid,
p_confirmed_name text,
p_reason text,
p_confirm_no_filed_declarations boolean,
p_confirm_retained_archive boolean
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_actor uuid := auth.uid();
v_role text;
v_source public.companies%ROWTYPE;
v_source_settings public.company_settings%ROWTYPE;
v_has_settings boolean := false;
v_display_name text;
v_eligibility jsonb;
v_new_company_id uuid := gen_random_uuid();
v_reset_id uuid := gen_random_uuid();
v_archived_at timestamptz := now();
v_provider_count integer := 0;
v_inbox_count integer := 0;
v_domain_count integer := 0;
v_invite_count integer := 0;
BEGIN
IF v_actor IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_FORBIDDEN');
END IF;
-- Reject outsiders and non-owners before taking tenant-wide row locks. The
-- same gate is repeated under lock below so a concurrent role change cannot
-- authorize execution.
SELECT role INTO v_role
FROM public.company_members
WHERE company_id = p_company_id
AND user_id = v_actor;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_NOT_FOUND');
END IF;
IF v_role <> 'owner' THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_FORBIDDEN');
END IF;
-- Serializes resets of the same source company. All work below is one
-- database transaction because RPC execution is transactional.
SELECT * INTO v_source
FROM public.companies
WHERE id = p_company_id
FOR UPDATE;
IF NOT FOUND OR v_source.archived_at IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_NOT_FOUND');
END IF;
-- Lock every existing row that can change eligibility. New child inserts
-- also wait on the source-company FOR UPDATE lock through their company_id
-- foreign key. This closes the race where a period is locked, a voucher is
-- committed, or a filing is recorded between the check and the archive.
PERFORM 1 FROM public.company_members WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.fiscal_periods WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.journal_entries WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.sie_imports WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.bank_file_imports WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.skattekonto_file_imports WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.agi_declarations WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.salary_runs WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.arsredovisning_submissions WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.rot_rut_payout_requests WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.skatteverket_api_audit_log WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.bank_connections WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.company_inboxes WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.company_inbound_domains WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.company_invitations WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.receipts WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.invoice_inbox_items WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.operations WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.pending_operations WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.invoice_deliveries WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.stripe_payouts WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.stripe_payment_events WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.webshop_orders WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.customers WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.suppliers WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.invoices WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.supplier_invoices WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.whatsapp_conversations WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.whatsapp_messages
WHERE conversation_id IN (
SELECT id FROM public.whatsapp_conversations WHERE company_id = p_company_id
) FOR UPDATE;
PERFORM 1 FROM public.provider_consents WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.company_subscriptions WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.company_capability_config WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.capability_grants WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.user_preferences WHERE active_company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.stripe_connections WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.woocommerce_connections WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.shopify_connections WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.skatteverket_tokens WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.skatteverket_company_connections WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.recurring_invoice_schedules WHERE company_id = p_company_id FOR UPDATE;
PERFORM 1 FROM public.accrual_schedule_installments WHERE company_id = p_company_id FOR UPDATE;
-- Repeat authorization after locking membership rows. A removed owner must
-- not retain authority from the pre-lock fast-fail check.
SELECT role INTO v_role
FROM public.company_members
WHERE company_id = p_company_id
AND user_id = v_actor;
IF NOT FOUND THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_NOT_FOUND');
END IF;
IF v_role <> 'owner' THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_FORBIDDEN');
END IF;
SELECT * INTO v_source_settings
FROM public.company_settings
WHERE company_id = p_company_id
FOR UPDATE;
v_has_settings := FOUND;
v_display_name := COALESCE(
NULLIF(trim(v_source_settings.company_name), ''),
NULLIF(trim(v_source.name), ''),
v_source.id::text
);
IF trim(COALESCE(p_confirmed_name, '')) <> v_display_name THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_CONFIRMATION_MISMATCH');
END IF;
IF char_length(trim(COALESCE(p_reason, ''))) NOT BETWEEN 20 AND 1000 THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_REASON_INVALID');
END IF;
IF p_confirm_no_filed_declarations IS DISTINCT FROM true
OR p_confirm_retained_archive IS DISTINCT FROM true THEN
RETURN jsonb_build_object('ok', false, 'code', 'COMPANY_RESET_CONFIRMATION_REQUIRED');
END IF;
-- Re-evaluate under the source-company row lock. No preview result is
-- trusted for execution.
v_eligibility := public.company_migration_reset_snapshot(p_company_id);
IF COALESCE((v_eligibility ->> 'eligible')::boolean, false) = false THEN
RETURN jsonb_build_object(
'ok', false,
'code', 'COMPANY_RESET_INELIGIBLE',
'details', v_eligibility
);
END IF;
-- Archive the source first inside this transaction. RLS excludes it after
-- commit, while every source-owned record remains untouched.
UPDATE public.companies
SET archived_at = v_archived_at,
archived_by = v_actor
WHERE id = p_company_id;
INSERT INTO public.companies (
id,
name,
org_number,
entity_type,
accounting_framework,
created_by,
team_id,
tic_snapshot,
tic_snapshot_fetched_at,
created_at,
updated_at
) VALUES (
v_new_company_id,
v_source.name,
v_source.org_number,
v_source.entity_type,
v_source.accounting_framework,
v_actor,
v_source.team_id,
v_source.tic_snapshot,
v_source.tic_snapshot_fetched_at,
v_archived_at,
v_archived_at
);
INSERT INTO public.company_members (
company_id,
user_id,
role,
source,
invited_by,
joined_at,
created_at,
updated_at
)
SELECT
v_new_company_id,
user_id,
role,
source,
invited_by,
joined_at,
v_archived_at,
v_archived_at
FROM public.company_members
WHERE company_id = p_company_id
AND user_id = v_actor;
-- Bootstrap the calling owner first. The membership guard then recognizes
-- that owner while copying any additional owners and members.
INSERT INTO public.company_members (
company_id,
user_id,
role,
source,
invited_by,
joined_at,
created_at,
updated_at
)
SELECT
v_new_company_id,
user_id,
role,
source,
invited_by,
joined_at,
v_archived_at,
v_archived_at
FROM public.company_members
WHERE company_id = p_company_id
AND user_id <> v_actor;
IF v_has_settings THEN
INSERT INTO public.company_settings
SELECT (jsonb_populate_record(
NULL::public.company_settings,
to_jsonb(v_source_settings) || jsonb_build_object(
'id', gen_random_uuid(),
'company_id', v_new_company_id,
'user_id', v_actor,
'created_at', v_archived_at,
'updated_at', v_archived_at,
'onboarding_complete', false,
'onboarding_step', 1,
'bookkeeping_locked_through', NULL,
'initial_setup_path', NULL,
'initial_setup_completed_at', NULL,
'initial_setup_dismissed_at', NULL
)
)).*;
END IF;
-- The replacement gets a fresh chart and a fresh voucher namespace. The
-- source voucher_sequences are deliberately not copied or recalculated.
PERFORM public.seed_chart_of_accounts(v_new_company_id, v_source.entity_type);
INSERT INTO public.cash_accounts (
company_id, ledger_account, currency, name, enabled, is_primary, source
) VALUES (
v_new_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual'
) ON CONFLICT (company_id, ledger_account) DO NOTHING;
-- Keep inbound document routing on the active company. Company insertion
-- auto-provisions a fresh inbox; replace it with the source's active address
-- so messages sent after commit cannot normally land in the archive.
SELECT count(*) INTO v_inbox_count
FROM public.company_inboxes
WHERE company_id = p_company_id
AND status = 'active';
IF v_inbox_count > 0 THEN
DELETE FROM public.company_inboxes
WHERE company_id = v_new_company_id
AND status = 'active';
UPDATE public.company_inboxes
SET company_id = v_new_company_id
WHERE company_id = p_company_id
AND status = 'active';
END IF;
UPDATE public.company_inbound_domains
SET company_id = v_new_company_id
WHERE company_id = p_company_id;
GET DIAGNOSTICS v_domain_count = ROW_COUNT;
-- Keep still-valid invitations pointed at the active company. Completed and
-- expired invitation history remains with the retained source.
UPDATE public.company_invitations
SET company_id = v_new_company_id
WHERE company_id = p_company_id
AND status = 'pending'
AND expires_at > v_archived_at;
GET DIAGNOSTICS v_invite_count = ROW_COUNT;
-- Carry only operational access required to redo the migration. Accounting
-- data and bank connections remain with the retained source company.
UPDATE public.provider_consents
SET company_id = v_new_company_id
WHERE company_id = p_company_id;
GET DIAGNOSTICS v_provider_count = ROW_COUNT;
UPDATE public.company_subscriptions
SET company_id = v_new_company_id
WHERE company_id = p_company_id;
UPDATE public.company_capability_config
SET company_id = v_new_company_id
WHERE company_id = p_company_id;
-- Company insertion seeds a new trial. Remove it before moving the original
-- grants so a reset can never extend or duplicate entitlement time.
DELETE FROM public.capability_grants
WHERE company_id = v_new_company_id;
UPDATE public.capability_grants
SET company_id = v_new_company_id
WHERE company_id = p_company_id;
UPDATE public.user_preferences
SET active_company_id = v_new_company_id,
updated_at = v_archived_at
WHERE active_company_id = p_company_id;
INSERT INTO public.company_migration_resets (
id,
source_company_id,
replacement_company_id,
actor_id,
reason,
confirmation_snapshot,
source_counts,
created_at
) VALUES (
v_reset_id,
p_company_id,
v_new_company_id,
v_actor,
trim(p_reason),
jsonb_build_object(
'confirmed_name', trim(p_confirmed_name),
'confirmed_no_filed_declarations', true,
'confirmed_retained_archive', true,
'source_created_at', v_source.created_at,
'provider_consents_transferred', v_provider_count,
'active_inboxes_transferred', v_inbox_count,
'inbound_domains_transferred', v_domain_count,
'pending_invitations_transferred', v_invite_count
),
v_eligibility -> 'counts',
v_archived_at
);
INSERT INTO public.audit_log (
user_id,
company_id,
action,
table_name,
record_id,
actor_id,
old_state,
new_state,
description
) VALUES (
v_actor,
p_company_id,
'UPDATE',
'companies',
p_company_id,
v_actor,
jsonb_build_object('archived_at', NULL),
jsonb_build_object(
'archived_at', v_archived_at,
'archived_by', v_actor,
'replacement_company_id', v_new_company_id,
'migration_reset_id', v_reset_id
),
'Source company archived for owner-confirmed migration reset'
), (
v_actor,
v_new_company_id,
'INSERT',
'companies',
v_new_company_id,
v_actor,
NULL,
jsonb_build_object(
'source_company_id', p_company_id,
'migration_reset_id', v_reset_id
),
'Replacement company created for owner-confirmed migration reset'
);
RETURN jsonb_build_object(
'ok', true,
'reset_id', v_reset_id,
'source_company_id', p_company_id,
'replacement_company_id', v_new_company_id,
'archived_at', v_archived_at,
'counts', v_eligibility -> 'counts'
);
END;
$$;
REVOKE ALL ON FUNCTION public.reset_company_for_migration(uuid, text, text, boolean, boolean)
FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.reset_company_for_migration(uuid, text, text, boolean, boolean)
TO authenticated;
NOTIFY pgrst, 'reload schema';