Files
accounted/supabase/migrations/20260721160000_annual_report_versions_and_compliance.sql
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

1115 lines
43 KiB
PL/PgSQL

-- Versioned annual reports, explicit K2 eligibility facts, disclosure
-- confirmations, signature evidence, and safer Bolagsverket submission state.
CREATE TABLE public.annual_report_profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
is_public_limited_company boolean,
is_in_liquidation boolean,
securities_traded_on_regulated_market boolean,
is_parent_company boolean,
parent_group_size text CHECK (parent_group_size IN ('none', 'small', 'large')),
prepares_consolidated_accounts boolean,
has_foreign_branch boolean,
has_crypto_assets boolean,
has_share_based_payments boolean,
has_convertible_debt boolean,
building_revenue_share_pct numeric(5, 2) CHECK (
building_revenue_share_pct IS NULL
OR building_revenue_share_pct BETWEEN 0 AND 100
),
has_material_deferred_tax boolean,
reporting_currency text NOT NULL DEFAULT 'SEK' CHECK (reporting_currency IN ('SEK', 'EUR')),
auditor_report_required boolean,
auditor_report_included boolean NOT NULL DEFAULT false,
dividend_prudence_confirmed boolean,
narrative_confirmed_at timestamptz,
k2_assessment_confirmed_at timestamptz,
signer_roster_confirmed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT annual_report_profiles_company_period_unique UNIQUE (company_id, fiscal_period_id),
CONSTRAINT annual_report_profiles_parent_consistency CHECK (
is_parent_company IS NOT false
OR (parent_group_size IS NULL AND prepares_consolidated_accounts IS NOT true)
)
);
CREATE INDEX idx_annual_report_profiles_period
ON public.annual_report_profiles (company_id, fiscal_period_id);
ALTER TABLE public.annual_report_profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY annual_report_profiles_select ON public.annual_report_profiles
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY annual_report_profiles_insert ON public.annual_report_profiles
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = annual_report_profiles.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
AND (user_id IS NULL OR user_id = auth.uid())
);
CREATE POLICY annual_report_profiles_update ON public.annual_report_profiles
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = annual_report_profiles.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = annual_report_profiles.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
CREATE POLICY annual_report_profiles_delete ON public.annual_report_profiles
FOR DELETE USING (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = annual_report_profiles.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
CREATE TRIGGER annual_report_profiles_updated_at
BEFORE UPDATE ON public.annual_report_profiles
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_annual_report_profiles
AFTER INSERT OR UPDATE OR DELETE ON public.annual_report_profiles
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
ALTER TABLE public.arsredovisning_narratives
ADD COLUMN long_term_debt_over_five_years_confirmed boolean NOT NULL DEFAULT false,
ADD COLUMN securities_pledged_confirmed boolean NOT NULL DEFAULT false,
ADD COLUMN contingent_liabilities_confirmed boolean NOT NULL DEFAULT false,
ADD COLUMN parent_company_confirmed boolean NOT NULL DEFAULT false,
ADD COLUMN agm_disposition_outcome text CHECK (
agm_disposition_outcome IN ('proposal_approved', 'alternative_decision')
),
ADD COLUMN agm_disposition_decision text CHECK (
agm_disposition_decision IS NULL OR length(agm_disposition_decision) <= 2000
),
ADD COLUMN proposed_dividend numeric(15, 2) CHECK (
proposed_dividend IS NULL OR proposed_dividend >= 0
),
ADD CONSTRAINT arsredovisning_narratives_agm_decision_consistency CHECK (
agm_disposition_outcome IS DISTINCT FROM 'alternative_decision'
OR nullif(trim(agm_disposition_decision), '') IS NOT NULL
);
CREATE TABLE public.annual_report_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT,
fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
version_number integer NOT NULL CHECK (version_number > 0),
schema_version text NOT NULL,
framework text NOT NULL CHECK (framework IN ('k2', 'k3')),
status text NOT NULL DEFAULT 'draft' CHECK (
status IN ('draft', 'ready_for_signature', 'signed', 'filed', 'registered', 'superseded')
),
report_data jsonb NOT NULL,
ixbrl_data jsonb,
content_hash text NOT NULL CHECK (content_hash ~ '^[a-f0-9]{64}$'),
taxonomy_version text,
entry_point text,
validation_summary jsonb NOT NULL DEFAULT '{}'::jsonb,
supersedes_version_id uuid REFERENCES public.annual_report_versions(id),
finalized_at timestamptz,
finalized_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT annual_report_versions_number_unique UNIQUE (company_id, fiscal_period_id, version_number),
CONSTRAINT annual_report_versions_content_unique UNIQUE (company_id, fiscal_period_id, content_hash),
CONSTRAINT annual_report_versions_finalized_consistency CHECK (
(status = 'draft' AND finalized_at IS NULL)
OR (status <> 'draft' AND finalized_at IS NOT NULL)
)
);
CREATE INDEX idx_annual_report_versions_period
ON public.annual_report_versions (company_id, fiscal_period_id, version_number DESC);
CREATE INDEX idx_annual_report_versions_status
ON public.annual_report_versions (company_id, status);
ALTER TABLE public.annual_report_versions ENABLE ROW LEVEL SECURITY;
CREATE POLICY annual_report_versions_select ON public.annual_report_versions
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY annual_report_versions_insert ON public.annual_report_versions
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = annual_report_versions.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
AND (user_id IS NULL OR user_id = auth.uid())
AND status = 'draft'
AND finalized_at IS NULL
AND finalized_by IS NULL
);
CREATE POLICY annual_report_versions_update ON public.annual_report_versions
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = annual_report_versions.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = annual_report_versions.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
AND status IN ('signed', 'filed')
);
CREATE POLICY annual_report_versions_no_delete ON public.annual_report_versions
FOR DELETE USING (false);
CREATE OR REPLACE FUNCTION public.enforce_annual_report_version_immutability()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.fiscal_period_id IS DISTINCT FROM OLD.fiscal_period_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.version_number IS DISTINCT FROM OLD.version_number
OR NEW.schema_version IS DISTINCT FROM OLD.schema_version
OR NEW.framework IS DISTINCT FROM OLD.framework
OR NEW.report_data IS DISTINCT FROM OLD.report_data
OR NEW.ixbrl_data IS DISTINCT FROM OLD.ixbrl_data
OR NEW.content_hash IS DISTINCT FROM OLD.content_hash
OR NEW.taxonomy_version IS DISTINCT FROM OLD.taxonomy_version
OR NEW.entry_point IS DISTINCT FROM OLD.entry_point
OR NEW.validation_summary IS DISTINCT FROM OLD.validation_summary
OR NEW.supersedes_version_id IS DISTINCT FROM OLD.supersedes_version_id
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
RAISE EXCEPTION 'Annual report version content is immutable (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
IF NEW.status IS DISTINCT FROM OLD.status AND NOT (
(OLD.status = 'draft' AND NEW.status IN ('ready_for_signature', 'superseded'))
OR (OLD.status = 'ready_for_signature' AND NEW.status IN ('signed', 'superseded'))
OR (OLD.status = 'signed' AND NEW.status IN ('filed', 'superseded'))
OR (OLD.status = 'filed' AND NEW.status IN ('registered', 'superseded'))
) THEN
RAISE EXCEPTION 'Invalid annual report version status transition: % to %', OLD.status, NEW.status
USING ERRCODE = 'check_violation';
END IF;
IF NEW.status = 'signed' AND OLD.status IS DISTINCT FROM 'signed' AND (
NOT EXISTS (
SELECT 1
FROM public.arsredovisning_signature_requests request
WHERE request.annual_report_version_id = NEW.id
)
OR EXISTS (
SELECT 1
FROM public.arsredovisning_signature_requests request
WHERE request.annual_report_version_id = NEW.id
AND request.status <> 'signed'
)
) THEN
RAISE EXCEPTION 'Annual report version cannot be signed before every locked signer has signed'
USING ERRCODE = 'check_violation';
END IF;
IF NEW.status = 'filed' AND OLD.status IS DISTINCT FROM 'filed' AND NOT EXISTS (
SELECT 1
FROM public.arsredovisning_submissions submission
WHERE submission.annual_report_version_id = NEW.id
AND submission.archive_status = 'stored'
AND submission.uploaded_at IS NOT NULL
AND submission.idnummer IS NOT NULL
) THEN
RAISE EXCEPTION 'Annual report version cannot be filed without an archived Bolagsverket upload receipt'
USING ERRCODE = 'check_violation';
END IF;
IF NEW.status = 'registered' AND OLD.status IS DISTINCT FROM 'registered' AND NOT EXISTS (
SELECT 1
FROM public.arsredovisning_submissions submission
WHERE submission.annual_report_version_id = NEW.id
AND submission.status = 'registrerad'
) THEN
RAISE EXCEPTION 'Annual report version cannot be registered without a registered Bolagsverket submission'
USING ERRCODE = 'check_violation';
END IF;
IF OLD.finalized_at IS NOT NULL AND NEW.finalized_at IS DISTINCT FROM OLD.finalized_at THEN
RAISE EXCEPTION 'Finalization metadata is immutable (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
IF OLD.finalized_by IS NOT NULL AND NEW.finalized_by IS DISTINCT FROM OLD.finalized_by THEN
RAISE EXCEPTION 'Finalization metadata is immutable (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_annual_report_version_immutability
BEFORE UPDATE ON public.annual_report_versions
FOR EACH ROW EXECUTE FUNCTION public.enforce_annual_report_version_immutability();
CREATE OR REPLACE FUNCTION public.block_annual_report_version_deletion()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'Annual report versions are retained as immutable accounting information (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END;
$$;
CREATE TRIGGER block_annual_report_version_deletion
BEFORE DELETE ON public.annual_report_versions
FOR EACH ROW EXECUTE FUNCTION public.block_annual_report_version_deletion();
CREATE TRIGGER audit_annual_report_versions
AFTER INSERT OR UPDATE OR DELETE ON public.annual_report_versions
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE TABLE public.annual_report_validation_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT,
fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE RESTRICT,
version_id uuid NOT NULL REFERENCES public.annual_report_versions(id) ON DELETE RESTRICT,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
validation_layer text NOT NULL CHECK (validation_layer IN ('local', 'arelle', 'bolagsverket')),
status text NOT NULL CHECK (status IN ('passed', 'warnings', 'failed', 'unavailable')),
validator_version text,
artifact_hash text CHECK (artifact_hash IS NULL OR artifact_hash ~ '^[a-f0-9]{64}$'),
issues jsonb NOT NULL DEFAULT '[]'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_annual_report_validation_runs_version
ON public.annual_report_validation_runs (company_id, version_id, created_at DESC);
ALTER TABLE public.annual_report_validation_runs ENABLE ROW LEVEL SECURITY;
CREATE POLICY annual_report_validation_runs_select ON public.annual_report_validation_runs
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY annual_report_validation_runs_insert ON public.annual_report_validation_runs
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = annual_report_validation_runs.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
AND (user_id IS NULL OR user_id = auth.uid())
);
CREATE POLICY annual_report_validation_runs_no_update ON public.annual_report_validation_runs
FOR UPDATE USING (false) WITH CHECK (false);
CREATE POLICY annual_report_validation_runs_no_delete ON public.annual_report_validation_runs
FOR DELETE USING (false);
CREATE TRIGGER audit_annual_report_validation_runs
AFTER INSERT OR UPDATE OR DELETE ON public.annual_report_validation_runs
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE OR REPLACE FUNCTION public.validate_annual_report_company_links()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
linked_version_company uuid;
linked_version_period uuid;
BEGIN
IF NOT EXISTS (
SELECT 1
FROM public.fiscal_periods fp
WHERE fp.id = NEW.fiscal_period_id
AND fp.company_id = NEW.company_id
) THEN
RAISE EXCEPTION 'Fiscal period does not belong to annual report company'
USING ERRCODE = 'foreign_key_violation';
END IF;
IF TG_TABLE_NAME = 'annual_report_validation_runs' THEN
SELECT company_id, fiscal_period_id
INTO linked_version_company, linked_version_period
FROM public.annual_report_versions
WHERE id = NEW.version_id;
IF linked_version_company IS DISTINCT FROM NEW.company_id
OR linked_version_period IS DISTINCT FROM NEW.fiscal_period_id THEN
RAISE EXCEPTION 'Annual report validation version belongs to another company or period'
USING ERRCODE = 'foreign_key_violation';
END IF;
ELSIF TG_TABLE_NAME = 'annual_report_versions' THEN
IF NEW.supersedes_version_id IS NOT NULL THEN
SELECT company_id, fiscal_period_id
INTO linked_version_company, linked_version_period
FROM public.annual_report_versions
WHERE id = NEW.supersedes_version_id;
IF linked_version_company IS DISTINCT FROM NEW.company_id
OR linked_version_period IS DISTINCT FROM NEW.fiscal_period_id THEN
RAISE EXCEPTION 'Superseded annual report version belongs to another company or period'
USING ERRCODE = 'foreign_key_violation';
END IF;
END IF;
ELSIF TG_TABLE_NAME IN ('arsredovisning_signature_requests', 'arsredovisning_submissions') THEN
IF NEW.annual_report_version_id IS NOT NULL THEN
SELECT company_id, fiscal_period_id
INTO linked_version_company, linked_version_period
FROM public.annual_report_versions
WHERE id = NEW.annual_report_version_id;
IF linked_version_company IS DISTINCT FROM NEW.company_id
OR linked_version_period IS DISTINCT FROM NEW.fiscal_period_id THEN
RAISE EXCEPTION 'Linked annual report version belongs to another company or period'
USING ERRCODE = 'foreign_key_violation';
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER validate_annual_report_profiles_company_links
BEFORE INSERT OR UPDATE ON public.annual_report_profiles
FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links();
CREATE TRIGGER validate_annual_report_versions_company_links
BEFORE INSERT OR UPDATE ON public.annual_report_versions
FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links();
CREATE TRIGGER validate_annual_report_validation_company_links
BEFORE INSERT OR UPDATE ON public.annual_report_validation_runs
FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links();
CREATE OR REPLACE FUNCTION public.create_annual_report_version(
p_company_id uuid,
p_fiscal_period_id uuid,
p_schema_version text,
p_framework text,
p_status text,
p_report_data jsonb,
p_ixbrl_data jsonb,
p_content_hash text,
p_taxonomy_version text,
p_entry_point text,
p_validation_summary jsonb,
p_user_id uuid
)
RETURNS SETOF public.annual_report_versions
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = public
AS $$
DECLARE
existing_row public.annual_report_versions%ROWTYPE;
next_version integer;
previous_version_id uuid;
BEGIN
IF auth.role() IS DISTINCT FROM 'service_role' AND p_user_id IS DISTINCT FROM auth.uid() THEN
RAISE EXCEPTION 'Annual report version user must match authenticated user'
USING ERRCODE = 'insufficient_privilege';
END IF;
IF p_status <> 'draft' THEN
RAISE EXCEPTION 'Direct annual report version creation only permits draft status'
USING ERRCODE = 'check_violation';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(p_company_id::text || ':' || p_fiscal_period_id::text, 0));
SELECT * INTO existing_row
FROM public.annual_report_versions
WHERE company_id = p_company_id
AND fiscal_period_id = p_fiscal_period_id
AND content_hash = p_content_hash;
IF FOUND THEN
RETURN NEXT existing_row;
RETURN;
END IF;
SELECT version_number, id
INTO next_version, previous_version_id
FROM public.annual_report_versions
WHERE company_id = p_company_id
AND fiscal_period_id = p_fiscal_period_id
ORDER BY version_number DESC
LIMIT 1;
next_version := coalesce(next_version, 0) + 1;
INSERT INTO public.annual_report_versions (
company_id,
fiscal_period_id,
user_id,
version_number,
schema_version,
framework,
status,
report_data,
ixbrl_data,
content_hash,
taxonomy_version,
entry_point,
validation_summary,
supersedes_version_id,
finalized_at,
finalized_by
) VALUES (
p_company_id,
p_fiscal_period_id,
p_user_id,
next_version,
p_schema_version,
p_framework,
p_status,
p_report_data,
p_ixbrl_data,
p_content_hash,
p_taxonomy_version,
p_entry_point,
p_validation_summary,
previous_version_id,
NULL,
NULL
)
RETURNING * INTO existing_row;
RETURN NEXT existing_row;
END;
$$;
REVOKE ALL ON FUNCTION public.create_annual_report_version(
uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid
) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.create_annual_report_version(
uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid
) TO authenticated, service_role;
ALTER TABLE public.arsredovisning_signature_requests
ADD COLUMN annual_report_version_id uuid REFERENCES public.annual_report_versions(id) ON DELETE RESTRICT,
ADD COLUMN signing_method text CHECK (
signing_method IN ('paper_original', 'advanced_e_signature', 'bankid', 'bolagsverket')
),
ADD COLUMN evidence_reference text CHECK (
evidence_reference IS NULL OR length(evidence_reference) <= 500
),
ADD COLUMN evidence_recorded_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
ADD COLUMN evidence_recorded_at timestamptz,
ADD CONSTRAINT arsredovisning_signature_evidence_consistency CHECK (
status <> 'signed'
OR annual_report_version_id IS NULL
OR (
signing_method IS NOT NULL
AND evidence_recorded_at IS NOT NULL
AND nullif(trim(evidence_reference), '') IS NOT NULL
)
);
CREATE OR REPLACE FUNCTION public.invalidate_annual_report_signer_roster_confirmation()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
affected_company_id uuid;
affected_fiscal_period_id uuid;
should_invalidate boolean := false;
BEGIN
IF TG_OP = 'INSERT' THEN
should_invalidate := NEW.annual_report_version_id IS NULL;
affected_company_id := NEW.company_id;
affected_fiscal_period_id := NEW.fiscal_period_id;
ELSIF TG_OP = 'DELETE' THEN
should_invalidate := OLD.annual_report_version_id IS NULL;
affected_company_id := OLD.company_id;
affected_fiscal_period_id := OLD.fiscal_period_id;
ELSE
should_invalidate := OLD.annual_report_version_id IS NULL
AND NEW.annual_report_version_id IS NULL
AND (
NEW.role IS DISTINCT FROM OLD.role
OR NEW.signer_name IS DISTINCT FROM OLD.signer_name
OR NEW.status IS DISTINCT FROM OLD.status
);
affected_company_id := NEW.company_id;
affected_fiscal_period_id := NEW.fiscal_period_id;
END IF;
IF should_invalidate THEN
UPDATE public.annual_report_profiles
SET signer_roster_confirmed_at = NULL
WHERE company_id = affected_company_id
AND fiscal_period_id = affected_fiscal_period_id
AND signer_roster_confirmed_at IS NOT NULL;
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER invalidate_annual_report_signer_roster_confirmation
AFTER INSERT OR UPDATE OR DELETE ON public.arsredovisning_signature_requests
FOR EACH ROW EXECUTE FUNCTION public.invalidate_annual_report_signer_roster_confirmation();
CREATE OR REPLACE FUNCTION public.prepare_annual_report_signature_slots(
p_company_id uuid,
p_fiscal_period_id uuid,
p_annual_report_version_id uuid,
p_user_id uuid
)
RETURNS void
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = public
AS $$
DECLARE
bound_slot_count integer;
BEGIN
IF auth.role() IS DISTINCT FROM 'service_role' AND p_user_id IS DISTINCT FROM auth.uid() THEN
RAISE EXCEPTION 'Annual report signature user must match authenticated user'
USING ERRCODE = 'insufficient_privilege';
END IF;
IF NOT EXISTS (
SELECT 1
FROM public.annual_report_versions
WHERE id = p_annual_report_version_id
AND company_id = p_company_id
AND fiscal_period_id = p_fiscal_period_id
AND status = 'ready_for_signature'
) THEN
RAISE EXCEPTION 'Annual report version is not ready for signatures'
USING ERRCODE = 'check_violation';
END IF;
UPDATE public.arsredovisning_signature_requests
SET annual_report_version_id = p_annual_report_version_id
WHERE company_id = p_company_id
AND fiscal_period_id = p_fiscal_period_id
AND status = 'pending'
AND annual_report_version_id IS NULL;
GET DIAGNOSTICS bound_slot_count = ROW_COUNT;
IF bound_slot_count = 0 THEN
INSERT INTO public.arsredovisning_signature_requests (
user_id,
company_id,
fiscal_period_id,
annual_report_version_id,
role,
signer_name,
status
)
SELECT
p_user_id,
p_company_id,
p_fiscal_period_id,
p_annual_report_version_id,
roster.role,
roster.signer_name,
'pending'
FROM (
SELECT DISTINCT ON (lower(trim(request.role)), lower(trim(request.signer_name)))
request.role,
request.signer_name
FROM public.arsredovisning_signature_requests request
JOIN public.annual_report_versions prior_version
ON prior_version.id = request.annual_report_version_id
WHERE prior_version.company_id = p_company_id
AND prior_version.fiscal_period_id = p_fiscal_period_id
AND prior_version.id <> p_annual_report_version_id
AND prior_version.version_number = (
SELECT max(candidate.version_number)
FROM public.annual_report_versions candidate
WHERE candidate.company_id = p_company_id
AND candidate.fiscal_period_id = p_fiscal_period_id
AND candidate.id <> p_annual_report_version_id
AND EXISTS (
SELECT 1
FROM public.arsredovisning_signature_requests candidate_request
WHERE candidate_request.annual_report_version_id = candidate.id
)
)
ORDER BY
lower(trim(request.role)),
lower(trim(request.signer_name)),
request.created_at DESC
) AS roster;
END IF;
END;
$$;
REVOKE ALL ON FUNCTION public.prepare_annual_report_signature_slots(
uuid, uuid, uuid, uuid
) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.prepare_annual_report_signature_slots(
uuid, uuid, uuid, uuid
) TO service_role;
CREATE OR REPLACE FUNCTION public.create_annual_report_version_with_signatures(
p_company_id uuid,
p_fiscal_period_id uuid,
p_schema_version text,
p_framework text,
p_status text,
p_report_data jsonb,
p_ixbrl_data jsonb,
p_content_hash text,
p_taxonomy_version text,
p_entry_point text,
p_validation_summary jsonb,
p_user_id uuid
)
RETURNS SETOF public.annual_report_versions
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = public
AS $$
DECLARE
version_row public.annual_report_versions%ROWTYPE;
live_roster_confirmation timestamptz;
BEGIN
IF auth.role() IS DISTINCT FROM 'service_role' THEN
RAISE EXCEPTION 'Final annual report versions may only be created by the trusted application service'
USING ERRCODE = 'insufficient_privilege';
END IF;
IF p_status <> 'ready_for_signature' THEN
RAISE EXCEPTION 'Signature-bound annual report creation requires ready_for_signature status'
USING ERRCODE = 'check_violation';
END IF;
IF jsonb_typeof(p_report_data) IS DISTINCT FROM 'object'
OR p_report_data #>> '{fiscal_period,id}' IS DISTINCT FROM p_fiscal_period_id::text
OR p_report_data ->> 'accounting_framework' IS DISTINCT FROM p_framework THEN
RAISE EXCEPTION 'Annual report payload does not match its company framework or fiscal period'
USING ERRCODE = 'check_violation';
END IF;
IF jsonb_typeof(p_validation_summary) IS DISTINCT FROM 'object'
OR p_validation_summary -> 'ok' IS DISTINCT FROM 'true'::jsonb
OR p_validation_summary -> 'error_count' IS DISTINCT FROM '0'::jsonb
OR p_validation_summary ->> 'stage' IS DISTINCT FROM 'signing'
OR jsonb_typeof(p_validation_summary -> 'profile') IS DISTINCT FROM 'object'
OR jsonb_typeof(p_validation_summary -> 'disclosures') IS DISTINCT FROM 'object'
OR jsonb_typeof(p_validation_summary -> 'eligibility') IS DISTINCT FROM 'object'
OR p_validation_summary #>> '{profile,company_id}' IS DISTINCT FROM p_company_id::text
OR p_validation_summary #>> '{profile,fiscal_period_id}' IS DISTINCT FROM p_fiscal_period_id::text THEN
RAISE EXCEPTION 'Annual report finalization requires a complete server validation snapshot'
USING ERRCODE = 'check_violation';
END IF;
SELECT signer_roster_confirmed_at
INTO live_roster_confirmation
FROM public.annual_report_profiles
WHERE company_id = p_company_id
AND fiscal_period_id = p_fiscal_period_id;
IF live_roster_confirmation IS NULL
OR (p_validation_summary #>> '{profile,signer_roster_confirmed_at}')::timestamptz
IS DISTINCT FROM live_roster_confirmation THEN
RAISE EXCEPTION 'Annual report signer roster must be confirmed against the current company representatives'
USING ERRCODE = 'check_violation';
END IF;
SELECT * INTO version_row
FROM public.create_annual_report_version(
p_company_id,
p_fiscal_period_id,
p_schema_version,
p_framework,
'draft',
p_report_data,
p_ixbrl_data,
p_content_hash,
p_taxonomy_version,
p_entry_point,
p_validation_summary,
p_user_id
);
IF version_row.status = 'draft' THEN
UPDATE public.annual_report_versions
SET status = 'ready_for_signature',
finalized_at = now(),
finalized_by = p_user_id
WHERE id = version_row.id
RETURNING * INTO version_row;
UPDATE public.annual_report_versions
SET status = 'superseded'
WHERE id = version_row.supersedes_version_id
AND status IN ('ready_for_signature', 'signed');
END IF;
IF version_row.status = 'ready_for_signature' THEN
PERFORM public.prepare_annual_report_signature_slots(
p_company_id,
p_fiscal_period_id,
version_row.id,
p_user_id
);
IF NOT EXISTS (
SELECT 1
FROM public.arsredovisning_signature_requests
WHERE annual_report_version_id = version_row.id
) THEN
RAISE EXCEPTION 'Annual report version requires at least one signer slot'
USING ERRCODE = 'check_violation';
END IF;
END IF;
RETURN NEXT version_row;
END;
$$;
REVOKE ALL ON FUNCTION public.create_annual_report_version_with_signatures(
uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid
) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.create_annual_report_version_with_signatures(
uuid, uuid, text, text, text, jsonb, jsonb, text, text, text, jsonb, uuid
) TO service_role;
CREATE INDEX idx_arsredovisning_sigreq_version
ON public.arsredovisning_signature_requests (company_id, annual_report_version_id);
WITH duplicate_roster_slots AS (
SELECT
id,
row_number() OVER (
PARTITION BY company_id, fiscal_period_id, lower(trim(role)), lower(trim(signer_name))
ORDER BY created_at, id
) AS duplicate_number
FROM public.arsredovisning_signature_requests
WHERE annual_report_version_id IS NULL AND status = 'pending'
)
DELETE FROM public.arsredovisning_signature_requests request
USING duplicate_roster_slots duplicate
WHERE request.id = duplicate.id
AND duplicate.duplicate_number > 1;
CREATE UNIQUE INDEX uq_arsredovisning_sigreq_unbound_roster
ON public.arsredovisning_signature_requests (
company_id,
fiscal_period_id,
(lower(trim(role))),
(lower(trim(signer_name)))
)
WHERE annual_report_version_id IS NULL AND status = 'pending';
CREATE TRIGGER validate_arsredovisning_signature_version_links
BEFORE INSERT OR UPDATE ON public.arsredovisning_signature_requests
FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links();
CREATE TRIGGER audit_arsredovisning_signature_requests
AFTER INSERT OR UPDATE OR DELETE ON public.arsredovisning_signature_requests
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE OR REPLACE FUNCTION public.serialize_annual_report_signature_signing()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.status = 'signed'
AND OLD.status IS DISTINCT FROM 'signed'
AND NEW.annual_report_version_id IS NOT NULL THEN
PERFORM pg_advisory_xact_lock(
hashtextextended(NEW.annual_report_version_id::text, 0)
);
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER serialize_annual_report_signature_signing
BEFORE UPDATE ON public.arsredovisning_signature_requests
FOR EACH ROW EXECUTE FUNCTION public.serialize_annual_report_signature_signing();
CREATE OR REPLACE FUNCTION public.complete_annual_report_version_signing()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.status = 'signed'
AND OLD.status IS DISTINCT FROM 'signed'
AND NEW.annual_report_version_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM public.arsredovisning_signature_requests pending_signature
WHERE pending_signature.annual_report_version_id = NEW.annual_report_version_id
AND pending_signature.status <> 'signed'
) THEN
UPDATE public.annual_report_versions
SET status = 'signed'
WHERE id = NEW.annual_report_version_id
AND company_id = NEW.company_id
AND fiscal_period_id = NEW.fiscal_period_id
AND status = 'ready_for_signature';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER complete_annual_report_version_signing
AFTER UPDATE ON public.arsredovisning_signature_requests
FOR EACH ROW EXECUTE FUNCTION public.complete_annual_report_version_signing();
CREATE OR REPLACE FUNCTION public.enforce_signed_signature_request_immutability()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.status = 'signed' THEN
IF to_jsonb(NEW) IS DISTINCT FROM to_jsonb(OLD) THEN
RAISE EXCEPTION 'Cannot modify a signed signature request (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
ELSIF OLD.status = 'declined' THEN
IF to_jsonb(NEW) IS DISTINCT FROM to_jsonb(OLD) THEN
RAISE EXCEPTION 'Cannot modify a declined signature request (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
END IF;
RETURN NEW;
END;
$$;
UPDATE public.arsredovisning_submissions
SET handling_typ = 'arsredovisning_komplett'
WHERE handling_typ = 'arsredovisning';
ALTER TABLE public.arsredovisning_submissions
DROP CONSTRAINT IF EXISTS arsredovisning_submissions_handling_typ_check,
DROP CONSTRAINT IF EXISTS arsredovisning_submissions_status_check;
ALTER TABLE public.arsredovisning_submissions
ADD CONSTRAINT arsredovisning_submissions_handling_typ_check CHECK (
handling_typ IN ('arsredovisning_komplett', 'arsredovisning_kompletteras', 'revisionsberattelse')
),
ADD CONSTRAINT arsredovisning_submissions_status_check CHECK (
status IN (
'draft', 'kontrollerad', 'sending', 'uploaded', 'unknown', 'inkommen',
'forelagd', 'komplettering', 'registrerad', 'avslutad', 'error'
)
),
ADD COLUMN annual_report_version_id uuid REFERENCES public.annual_report_versions(id) ON DELETE RESTRICT,
ADD COLUMN request_key text,
ADD COLUMN archive_status text NOT NULL DEFAULT 'pending' CHECK (
archive_status IN ('pending', 'stored', 'failed')
),
ADD COLUMN upload_started_at timestamptz,
ADD COLUMN external_receipt jsonb;
CREATE UNIQUE INDEX uq_arsredovisning_submissions_request_key
ON public.arsredovisning_submissions (company_id, environment, request_key)
WHERE request_key IS NOT NULL;
CREATE UNIQUE INDEX uq_arsredovisning_submissions_remote_id
ON public.arsredovisning_submissions (environment, idnummer)
WHERE idnummer IS NOT NULL;
CREATE TRIGGER validate_arsredovisning_submission_version_links
BEFORE INSERT OR UPDATE ON public.arsredovisning_submissions
FOR EACH ROW EXECUTE FUNCTION public.validate_annual_report_company_links();
CREATE OR REPLACE FUNCTION public.enforce_arsred_submission_immutability()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.uploaded_at IS NOT NULL OR OLD.status IN ('sending', 'unknown', 'uploaded', 'inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad') THEN
IF NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.fiscal_period_id IS DISTINCT FROM OLD.fiscal_period_id
OR NEW.annual_report_version_id IS DISTINCT FROM OLD.annual_report_version_id
OR NEW.handling_typ IS DISTINCT FROM OLD.handling_typ
OR NEW.taxonomy_version IS DISTINCT FROM OLD.taxonomy_version
OR NEW.entry_point IS DISTINCT FROM OLD.entry_point
OR NEW.environment IS DISTINCT FROM OLD.environment
OR NEW.request_key IS DISTINCT FROM OLD.request_key
OR (OLD.idnummer IS NOT NULL AND NEW.idnummer IS DISTINCT FROM OLD.idnummer)
OR (OLD.sha256_checksumma IS NOT NULL AND NEW.sha256_checksumma IS DISTINCT FROM OLD.sha256_checksumma)
OR NEW.kontrollsumma IS DISTINCT FROM OLD.kontrollsumma
OR NEW.dokument_id IS DISTINCT FROM OLD.dokument_id
OR NEW.archive_status IS DISTINCT FROM OLD.archive_status
OR NEW.undertecknare_pnr_hash IS DISTINCT FROM OLD.undertecknare_pnr_hash
OR NEW.avsandare_pnr_hash IS DISTINCT FROM OLD.avsandare_pnr_hash
OR NEW.upload_started_at IS DISTINCT FROM OLD.upload_started_at
OR (OLD.bolagsverket_url IS NOT NULL AND NEW.bolagsverket_url IS DISTINCT FROM OLD.bolagsverket_url)
OR (OLD.external_receipt IS NOT NULL AND NEW.external_receipt IS DISTINCT FROM OLD.external_receipt)
OR (OLD.uploaded_at IS NOT NULL AND NEW.uploaded_at IS DISTINCT FROM OLD.uploaded_at) THEN
RAISE EXCEPTION 'Inlämnad årsredovisning kan inte ändras (submission %)', OLD.id
USING ERRCODE = 'P0001';
END IF;
END IF;
IF NEW.status IS DISTINCT FROM OLD.status AND NOT (
(OLD.status = 'draft' AND NEW.status IN ('kontrollerad', 'error'))
OR (OLD.status = 'kontrollerad' AND NEW.status IN ('kontrollerad', 'sending', 'error', 'draft'))
OR (OLD.status = 'sending' AND NEW.status IN ('uploaded', 'unknown'))
OR (OLD.status = 'unknown' AND NEW.status IN ('uploaded', 'inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad'))
OR (OLD.status = 'uploaded' AND NEW.status IN ('inkommen', 'forelagd', 'komplettering', 'registrerad', 'avslutad', 'error'))
OR (OLD.status = 'inkommen' AND NEW.status IN ('forelagd', 'komplettering', 'registrerad', 'avslutad'))
OR (OLD.status = 'forelagd' AND NEW.status IN ('komplettering', 'registrerad', 'avslutad'))
OR (OLD.status = 'komplettering' AND NEW.status IN ('forelagd', 'registrerad', 'avslutad'))
OR (OLD.status = 'error' AND NEW.status IN ('draft', 'kontrollerad'))
) THEN
RAISE EXCEPTION 'Ogiltig statusövergång för årsredovisningsinlämning: % till %', OLD.status, NEW.status
USING ERRCODE = 'P0001';
END IF;
RETURN NEW;
END;
$$;
DROP POLICY IF EXISTS "arsredovisning_sigreq_insert"
ON public.arsredovisning_signature_requests;
DROP POLICY IF EXISTS "arsredovisning_sigreq_update"
ON public.arsredovisning_signature_requests;
DROP POLICY IF EXISTS "arsredovisning_sigreq_delete"
ON public.arsredovisning_signature_requests;
CREATE POLICY "arsredovisning_sigreq_insert"
ON public.arsredovisning_signature_requests FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = arsredovisning_signature_requests.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
CREATE POLICY "arsredovisning_sigreq_update"
ON public.arsredovisning_signature_requests FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = arsredovisning_signature_requests.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = arsredovisning_signature_requests.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
CREATE POLICY "arsredovisning_sigreq_delete"
ON public.arsredovisning_signature_requests FOR DELETE
USING (
status = 'pending'
AND annual_report_version_id IS NULL
AND EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = arsredovisning_signature_requests.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
DROP POLICY IF EXISTS "insert own-company arsredovisning submissions"
ON public.arsredovisning_submissions;
DROP POLICY IF EXISTS "update own-company arsredovisning submissions"
ON public.arsredovisning_submissions;
CREATE POLICY "insert own-company arsredovisning submissions"
ON public.arsredovisning_submissions FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = arsredovisning_submissions.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
CREATE POLICY "update own-company arsredovisning submissions"
ON public.arsredovisning_submissions FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = arsredovisning_submissions.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = arsredovisning_submissions.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
DROP POLICY IF EXISTS "insert own-company avtal acceptances"
ON public.bolagsverket_avtal_acceptances;
CREATE POLICY "insert own-company avtal acceptances"
ON public.bolagsverket_avtal_acceptances FOR INSERT
WITH CHECK (
user_id = auth.uid()
AND EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = bolagsverket_avtal_acceptances.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
DROP POLICY IF EXISTS "insert own-company bolagsverket subscriptions"
ON public.bolagsverket_subscriptions;
DROP POLICY IF EXISTS "update own-company bolagsverket subscriptions"
ON public.bolagsverket_subscriptions;
DROP POLICY IF EXISTS "delete own-company bolagsverket subscriptions"
ON public.bolagsverket_subscriptions;
CREATE POLICY "insert own-company bolagsverket subscriptions"
ON public.bolagsverket_subscriptions FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = bolagsverket_subscriptions.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
CREATE POLICY "update own-company bolagsverket subscriptions"
ON public.bolagsverket_subscriptions FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = bolagsverket_subscriptions.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = bolagsverket_subscriptions.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
CREATE POLICY "delete own-company bolagsverket subscriptions"
ON public.bolagsverket_subscriptions FOR DELETE
USING (
EXISTS (
SELECT 1 FROM public.company_members
WHERE company_members.company_id = bolagsverket_subscriptions.company_id
AND company_members.user_id = auth.uid()
AND company_members.role IN ('owner', 'admin', 'member')
)
);
NOTIFY pgrst, 'reload schema';