New Base func

This commit is contained in:
Jakob Wennberg
2026-02-19 09:48:02 +01:00
parent afc9f69def
commit cdf1dcc4c8
73 changed files with 11036 additions and 51 deletions
@@ -0,0 +1,80 @@
-- Migration 11: ALTER Existing Tables
-- Add compliance-critical columns to chart_of_accounts, journal_entries,
-- journal_entry_lines, and fiscal_periods
-- =============================================================================
-- 1. chart_of_accounts: Add SRU code for Skatteverket tax filing
-- =============================================================================
ALTER TABLE public.chart_of_accounts
ADD COLUMN IF NOT EXISTS sru_code text;
-- =============================================================================
-- 2. journal_entries: Add compliance columns
-- =============================================================================
-- Track when draft became posted
ALTER TABLE public.journal_entries
ADD COLUMN IF NOT EXISTS committed_at timestamptz;
-- Link to storno entry that reversed this
ALTER TABLE public.journal_entries
ADD COLUMN IF NOT EXISTS reversed_by_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
-- Link to entry this storno reverses
ALTER TABLE public.journal_entries
ADD COLUMN IF NOT EXISTS reverses_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
-- Link to original in correction chain
ALTER TABLE public.journal_entries
ADD COLUMN IF NOT EXISTS correction_of_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
-- Expand source_type CHECK to include storno, correction, import, system
-- First drop the existing check constraint, then re-add with expanded values
ALTER TABLE public.journal_entries
DROP CONSTRAINT IF EXISTS journal_entries_source_type_check;
ALTER TABLE public.journal_entries
ADD CONSTRAINT journal_entries_source_type_check
CHECK (source_type IN (
'manual', 'bank_transaction', 'invoice_created',
'invoice_paid', 'credit_note', 'salary_payment',
'opening_balance', 'year_end',
'storno', 'correction', 'import', 'system'
));
-- Indexes for the new FK columns
CREATE INDEX IF NOT EXISTS idx_journal_entries_reversed_by_id ON public.journal_entries (reversed_by_id);
CREATE INDEX IF NOT EXISTS idx_journal_entries_reverses_id ON public.journal_entries (reverses_id);
CREATE INDEX IF NOT EXISTS idx_journal_entries_correction_of_id ON public.journal_entries (correction_of_id);
-- =============================================================================
-- 3. journal_entry_lines: Add dimension columns
-- =============================================================================
-- Decoupled tax code reference
ALTER TABLE public.journal_entry_lines
ADD COLUMN IF NOT EXISTS tax_code text;
-- Kostnadsställe dimension
ALTER TABLE public.journal_entry_lines
ADD COLUMN IF NOT EXISTS cost_center text;
-- Projekt dimension
ALTER TABLE public.journal_entry_lines
ADD COLUMN IF NOT EXISTS project text;
CREATE INDEX IF NOT EXISTS idx_journal_entry_lines_tax_code ON public.journal_entry_lines (tax_code);
CREATE INDEX IF NOT EXISTS idx_journal_entry_lines_cost_center ON public.journal_entry_lines (cost_center);
CREATE INDEX IF NOT EXISTS idx_journal_entry_lines_project ON public.journal_entry_lines (project);
-- =============================================================================
-- 4. fiscal_periods: Add lock and retention columns
-- =============================================================================
-- Period lock timestamp (separate from is_closed)
ALTER TABLE public.fiscal_periods
ADD COLUMN IF NOT EXISTS locked_at timestamptz;
-- Auto-calculated: period_end + 7 years
ALTER TABLE public.fiscal_periods
ADD COLUMN IF NOT EXISTS retention_expires_at date;
@@ -0,0 +1,123 @@
-- Migration 12: Tax Code Engine
-- Decoupled tax codes for momsdeklaration mapping
-- =============================================================================
-- 1. tax_codes table
-- =============================================================================
CREATE TABLE public.tax_codes (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid REFERENCES auth.users ON DELETE CASCADE,
code text NOT NULL,
description text NOT NULL,
rate numeric NOT NULL DEFAULT 0,
-- Momsdeklaration ruta mapping
moms_basis_boxes text[] DEFAULT '{}', -- e.g. {'10'} for 25% basis
moms_tax_boxes text[] DEFAULT '{}', -- e.g. {'05'} for 25% output VAT
moms_input_boxes text[] DEFAULT '{}', -- e.g. {'48'} for input VAT
-- Classification flags
is_output_vat boolean DEFAULT false,
is_reverse_charge boolean DEFAULT false,
is_eu boolean DEFAULT false,
is_export boolean DEFAULT false,
is_oss boolean DEFAULT false,
is_system boolean DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- System codes have NULL user_id; user codes have unique code per user
UNIQUE (user_id, code)
);
ALTER TABLE public.tax_codes ENABLE ROW LEVEL SECURITY;
-- Users can see their own + system (user_id IS NULL) codes
CREATE POLICY "tax_codes_select" ON public.tax_codes
FOR SELECT USING (auth.uid() = user_id OR user_id IS NULL);
CREATE POLICY "tax_codes_insert" ON public.tax_codes
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "tax_codes_update" ON public.tax_codes
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "tax_codes_delete" ON public.tax_codes
FOR DELETE USING (auth.uid() = user_id);
CREATE INDEX idx_tax_codes_user_id ON public.tax_codes (user_id);
CREATE INDEX idx_tax_codes_code ON public.tax_codes (code);
CREATE TRIGGER tax_codes_updated_at
BEFORE UPDATE ON public.tax_codes
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- =============================================================================
-- 2. Seed system tax codes (12 standard Swedish tax codes)
-- =============================================================================
INSERT INTO public.tax_codes (user_id, code, description, rate, moms_basis_boxes, moms_tax_boxes, moms_input_boxes, is_output_vat, is_reverse_charge, is_eu, is_export, is_oss, is_system)
VALUES
-- Output VAT (utgående moms)
(NULL, 'MP1', 'Utgående moms 25%', 0.25, '{10}', '{05}', '{}', true, false, false, false, false, true),
(NULL, 'MP2', 'Utgående moms 12%', 0.12, '{11}', '{06}', '{}', true, false, false, false, false, true),
(NULL, 'MP3', 'Utgående moms 6%', 0.06, '{12}', '{07}', '{}', true, false, false, false, false, true),
-- Input VAT (ingående moms)
(NULL, 'MPI', 'Ingående moms 25%', 0.25, '{}', '{}', '{48}', false, false, false, false, false, true),
(NULL, 'MPI12', 'Ingående moms 12%', 0.12, '{}', '{}', '{48}', false, false, false, false, false, true),
(NULL, 'MPI6', 'Ingående moms 6%', 0.06, '{}', '{}', '{48}', false, false, false, false, false, true),
-- EU / International
(NULL, 'IV', 'Intra-EU förvärv (omvänd moms)', 0.25, '{20,21}', '{30,31}', '{48}', false, true, true, false, false, true),
(NULL, 'EUS', 'EU försäljning (omvänd moms)', 0, '{39}', '{}', '{}', false, true, true, false, false, true),
(NULL, 'IP', 'Import (tull/moms)', 0.25, '{22}', '{32}', '{48}', false, false, false, false, false, true),
(NULL, 'EXP', 'Export utanför EU', 0, '{40}', '{}', '{}', false, false, false, true, false, true),
-- OSS (One Stop Shop)
(NULL, 'OSS', 'OSS försäljning EU konsument', 0, '{}', '{}', '{}', false, false, true, false, true, true),
-- Exempt
(NULL, 'NONE', 'Momsfritt', 0, '{}', '{}', '{}', false, false, false, false, false, true);
-- =============================================================================
-- 3. Function to copy system tax codes to user scope
-- =============================================================================
CREATE OR REPLACE FUNCTION public.seed_tax_codes_for_user(p_user_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_count integer;
BEGIN
-- Only seed if user has no existing tax codes
SELECT count(*) INTO v_count
FROM public.tax_codes
WHERE user_id = p_user_id;
IF v_count > 0 THEN
RETURN;
END IF;
INSERT INTO public.tax_codes (user_id, code, description, rate, moms_basis_boxes, moms_tax_boxes, moms_input_boxes, is_output_vat, is_reverse_charge, is_eu, is_export, is_oss, is_system)
SELECT
p_user_id,
code,
description,
rate,
moms_basis_boxes,
moms_tax_boxes,
moms_input_boxes,
is_output_vat,
is_reverse_charge,
is_eu,
is_export,
is_oss,
false -- user copies are NOT system
FROM public.tax_codes
WHERE user_id IS NULL AND is_system = true;
END;
$$;
GRANT EXECUTE ON FUNCTION public.seed_tax_codes_for_user(uuid) TO authenticated;
@@ -0,0 +1,63 @@
-- Migration 13: Document Archive
-- WORM-style document storage with hash integrity and version chain
-- =============================================================================
-- 1. document_attachments table
-- =============================================================================
CREATE TABLE public.document_attachments (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
-- Storage
storage_path text NOT NULL,
file_name text NOT NULL,
file_size_bytes bigint,
mime_type text,
-- Integrity
sha256_hash text NOT NULL,
-- Version chain (WORM: Write Once, Read Many)
version integer NOT NULL DEFAULT 1,
original_id uuid REFERENCES public.document_attachments(id) ON DELETE SET NULL,
superseded_by_id uuid REFERENCES public.document_attachments(id) ON DELETE SET NULL,
is_current_version boolean NOT NULL DEFAULT true,
-- Digitization metadata
uploaded_by uuid REFERENCES auth.users ON DELETE SET NULL,
upload_source text CHECK (upload_source IN (
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system'
)),
digitization_date timestamptz DEFAULT now(),
-- Linkage to journal entries (ON DELETE RESTRICT prevents deletion of linked entries)
journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE RESTRICT,
journal_entry_line_id uuid REFERENCES public.journal_entry_lines(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE public.document_attachments ENABLE ROW LEVEL SECURITY;
-- RLS: select, insert, update for owner. NO DELETE policy (handled by trigger).
CREATE POLICY "document_attachments_select" ON public.document_attachments
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "document_attachments_insert" ON public.document_attachments
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "document_attachments_update" ON public.document_attachments
FOR UPDATE USING (auth.uid() = user_id);
-- Intentionally NO DELETE policy -- deletion is blocked by trigger
CREATE INDEX idx_document_attachments_user_id ON public.document_attachments (user_id);
CREATE INDEX idx_document_attachments_journal_entry_id ON public.document_attachments (journal_entry_id);
CREATE INDEX idx_document_attachments_journal_entry_line_id ON public.document_attachments (journal_entry_line_id);
CREATE INDEX idx_document_attachments_sha256_hash ON public.document_attachments (sha256_hash);
CREATE INDEX idx_document_attachments_original_id ON public.document_attachments (original_id);
CREATE TRIGGER document_attachments_updated_at
BEFORE UPDATE ON public.document_attachments
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
@@ -0,0 +1,59 @@
-- Migration 14: Audit Log
-- Append-only audit log for all compliance-critical mutations
-- =============================================================================
-- 1. audit_log table
-- =============================================================================
CREATE TABLE public.audit_log (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL, -- No FK cascade: survives user deletion
action text NOT NULL CHECK (action IN (
'INSERT', 'UPDATE', 'DELETE',
'COMMIT', 'REVERSE', 'CORRECT',
'LOCK_PERIOD', 'CLOSE_PERIOD',
'DOCUMENT_DELETE_BLOCKED', 'RETENTION_BLOCK',
'SECURITY_EVENT'
)),
table_name text,
record_id uuid,
actor_id uuid,
old_state jsonb,
new_state jsonb,
description text,
created_at timestamptz NOT NULL DEFAULT now()
-- Intentionally NO updated_at: append-only
);
ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY;
-- Users can only read their own audit log entries
CREATE POLICY "audit_log_select" ON public.audit_log
FOR SELECT USING (auth.uid() = user_id);
-- No INSERT policy for normal users -- audit log is written by SECURITY DEFINER triggers
-- No UPDATE or DELETE policies -- immutability enforced by triggers below
CREATE INDEX idx_audit_log_user_id ON public.audit_log (user_id);
CREATE INDEX idx_audit_log_table_record ON public.audit_log (table_name, record_id);
CREATE INDEX idx_audit_log_action ON public.audit_log (action);
CREATE INDEX idx_audit_log_created_at ON public.audit_log (created_at);
-- =============================================================================
-- 2. Immutability triggers: block UPDATE and DELETE on audit_log
-- =============================================================================
CREATE OR REPLACE FUNCTION public.audit_log_immutable()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'Audit log entries cannot be modified or deleted';
END;
$$;
CREATE TRIGGER audit_log_no_update
BEFORE UPDATE ON public.audit_log
FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
CREATE TRIGGER audit_log_no_delete
BEFORE DELETE ON public.audit_log
FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
@@ -0,0 +1,68 @@
-- Migration 15: Dimensions
-- Cost centers (kostnadsställen) and projects for journal entry lines
-- =============================================================================
-- 1. cost_centers table
-- =============================================================================
CREATE TABLE public.cost_centers (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
code text NOT NULL,
name text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, code)
);
ALTER TABLE public.cost_centers ENABLE ROW LEVEL SECURITY;
CREATE POLICY "cost_centers_select" ON public.cost_centers
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "cost_centers_insert" ON public.cost_centers
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "cost_centers_update" ON public.cost_centers
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "cost_centers_delete" ON public.cost_centers
FOR DELETE USING (auth.uid() = user_id);
CREATE INDEX idx_cost_centers_user_id ON public.cost_centers (user_id);
CREATE TRIGGER cost_centers_updated_at
BEFORE UPDATE ON public.cost_centers
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- =============================================================================
-- 2. projects table
-- =============================================================================
CREATE TABLE public.projects (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
code text NOT NULL,
name text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
start_date date,
end_date date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, code)
);
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "projects_select" ON public.projects
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "projects_insert" ON public.projects
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "projects_update" ON public.projects
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "projects_delete" ON public.projects
FOR DELETE USING (auth.uid() = user_id);
CREATE INDEX idx_projects_user_id ON public.projects (user_id);
CREATE TRIGGER projects_updated_at
BEFORE UPDATE ON public.projects
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
@@ -0,0 +1,153 @@
-- Migration 16: Voucher Sequence Hardening
-- Concurrent-safe voucher numbering and balance constraint
-- =============================================================================
-- 1. voucher_sequences table
-- =============================================================================
CREATE TABLE public.voucher_sequences (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
fiscal_period_id uuid REFERENCES public.fiscal_periods(id) ON DELETE CASCADE NOT NULL,
voucher_series text NOT NULL DEFAULT 'A',
last_number integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, fiscal_period_id, voucher_series)
);
ALTER TABLE public.voucher_sequences ENABLE ROW LEVEL SECURITY;
CREATE POLICY "voucher_sequences_select" ON public.voucher_sequences
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "voucher_sequences_insert" ON public.voucher_sequences
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "voucher_sequences_update" ON public.voucher_sequences
FOR UPDATE USING (auth.uid() = user_id);
CREATE TRIGGER voucher_sequences_updated_at
BEFORE UPDATE ON public.voucher_sequences
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- =============================================================================
-- 2. Replace next_voucher_number() with concurrent-safe version
-- Uses INSERT ON CONFLICT + UPDATE RETURNING for row-level locking
-- =============================================================================
CREATE OR REPLACE FUNCTION public.next_voucher_number(
p_user_id uuid,
p_fiscal_period_id uuid,
p_series text DEFAULT 'A'
)
RETURNS integer
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_next integer;
BEGIN
-- INSERT or UPDATE with row-level lock (prevents race conditions)
INSERT INTO public.voucher_sequences (user_id, fiscal_period_id, voucher_series, last_number)
VALUES (p_user_id, p_fiscal_period_id, p_series, 1)
ON CONFLICT (user_id, fiscal_period_id, voucher_series)
DO UPDATE SET
last_number = public.voucher_sequences.last_number + 1,
updated_at = now()
RETURNING last_number INTO v_next;
RETURN v_next;
END;
$$;
-- =============================================================================
-- 3. Balance constraint trigger for posted entries
-- Validates debit == credit (DEFERRABLE to allow batch line inserts)
-- =============================================================================
CREATE OR REPLACE FUNCTION public.check_journal_entry_balance()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_total_debit numeric;
v_total_credit numeric;
v_status text;
v_entry_id uuid;
BEGIN
-- Determine the entry ID based on trigger context
IF TG_TABLE_NAME = 'journal_entries' THEN
v_entry_id := NEW.id;
v_status := NEW.status;
ELSE
v_entry_id := NEW.journal_entry_id;
SELECT status INTO v_status
FROM public.journal_entries
WHERE id = v_entry_id;
END IF;
-- Only enforce on posted entries
IF v_status != 'posted' THEN
RETURN NEW;
END IF;
SELECT COALESCE(SUM(debit_amount), 0), COALESCE(SUM(credit_amount), 0)
INTO v_total_debit, v_total_credit
FROM public.journal_entry_lines
WHERE journal_entry_id = v_entry_id;
IF ROUND(v_total_debit, 2) != ROUND(v_total_credit, 2) THEN
RAISE EXCEPTION 'Journal entry % is not balanced: debit=% credit=%',
v_entry_id, v_total_debit, v_total_credit;
END IF;
IF v_total_debit = 0 THEN
RAISE EXCEPTION 'Journal entry % has zero total', v_entry_id;
END IF;
RETURN NEW;
END;
$$;
-- Apply as DEFERRABLE constraint trigger on journal_entries status change
CREATE CONSTRAINT TRIGGER check_balance_on_post
AFTER UPDATE ON public.journal_entries
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
WHEN (NEW.status = 'posted' AND OLD.status = 'draft')
EXECUTE FUNCTION public.check_journal_entry_balance();
-- =============================================================================
-- 4. Function to detect voucher gaps for compliance reporting
-- =============================================================================
CREATE OR REPLACE FUNCTION public.detect_voucher_gaps(
p_user_id uuid,
p_fiscal_period_id uuid,
p_series text DEFAULT 'A'
)
RETURNS TABLE (
gap_start integer,
gap_end integer
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
WITH numbered AS (
SELECT voucher_number,
LEAD(voucher_number) OVER (ORDER BY voucher_number) AS next_number
FROM public.journal_entries
WHERE user_id = p_user_id
AND fiscal_period_id = p_fiscal_period_id
AND voucher_series = p_series
AND status != 'draft'
ORDER BY voucher_number
)
SELECT
voucher_number + 1 AS gap_start,
next_number - 1 AS gap_end
FROM numbered
WHERE next_number IS NOT NULL
AND next_number > voucher_number + 1;
END;
$$;
GRANT EXECUTE ON FUNCTION public.detect_voucher_gaps(uuid, uuid, text) TO authenticated;
@@ -0,0 +1,293 @@
-- Migration 17: Enforcement Triggers
-- Critical compliance triggers for Bokföringslagen
-- =============================================================================
-- 1. enforce_journal_entry_immutability()
-- BEFORE UPDATE/DELETE on journal_entries
-- Allows: draft→draft edits, draft→posted commit, posted→reversed transition
-- Blocks: all other updates/deletes on posted/reversed entries
-- =============================================================================
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
-- Allow deleting drafts
IF OLD.status = 'draft' THEN
RETURN OLD;
END IF;
RAISE EXCEPTION 'Cannot delete a % journal entry (id: %)', OLD.status, OLD.id;
END IF;
-- TG_OP = 'UPDATE'
-- Allow: draft → draft (editing a draft)
IF OLD.status = 'draft' AND NEW.status = 'draft' THEN
RETURN NEW;
END IF;
-- Allow: draft → posted (committing)
IF OLD.status = 'draft' AND NEW.status = 'posted' THEN
RETURN NEW;
END IF;
-- Allow: posted → reversed (storno reversal)
IF OLD.status = 'posted' AND NEW.status = 'reversed' THEN
-- Only allow setting reversed_by_id during this transition
IF NEW.description != OLD.description
OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
RETURN NEW;
END IF;
-- Block all other transitions
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokföringslagen.',
OLD.status, OLD.id;
END;
$$;
CREATE TRIGGER enforce_journal_entry_immutability
BEFORE UPDATE OR DELETE ON public.journal_entries
FOR EACH ROW EXECUTE FUNCTION public.enforce_journal_entry_immutability();
-- =============================================================================
-- 2. enforce_journal_entry_line_immutability()
-- BEFORE UPDATE/DELETE on journal_entry_lines
-- Blocks modifications to lines of posted/reversed entries
-- =============================================================================
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_status text;
BEGIN
-- Get the parent entry status
SELECT status INTO v_status
FROM public.journal_entries
WHERE id = COALESCE(OLD.journal_entry_id, NEW.journal_entry_id);
-- Allow modifications to lines of draft entries
IF v_status = 'draft' THEN
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END IF;
-- Block modifications to lines of posted/reversed entries
RAISE EXCEPTION 'Cannot % lines of a % journal entry. Committed entries are immutable per Bokföringslagen.',
TG_OP, v_status;
END;
$$;
CREATE TRIGGER enforce_journal_entry_line_immutability
BEFORE UPDATE OR DELETE ON public.journal_entry_lines
FOR EACH ROW EXECUTE FUNCTION public.enforce_journal_entry_line_immutability();
-- =============================================================================
-- 3. enforce_period_lock()
-- BEFORE INSERT/UPDATE on journal_entries
-- Rejects writes when fiscal_periods.is_closed=true OR locked_at IS NOT NULL
-- =============================================================================
CREATE OR REPLACE FUNCTION public.enforce_period_lock()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_is_closed boolean;
v_locked_at timestamptz;
v_period_name text;
BEGIN
SELECT is_closed, locked_at, name
INTO v_is_closed, v_locked_at, v_period_name
FROM public.fiscal_periods
WHERE id = NEW.fiscal_period_id;
IF v_is_closed OR v_locked_at IS NOT NULL THEN
RAISE EXCEPTION 'Cannot write to locked/closed fiscal period "%" (is_closed=%, locked_at=%)',
v_period_name, v_is_closed, v_locked_at;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_period_lock
BEFORE INSERT OR UPDATE ON public.journal_entries
FOR EACH ROW EXECUTE FUNCTION public.enforce_period_lock();
-- =============================================================================
-- 4. enforce_period_lock_documents()
-- BEFORE INSERT/UPDATE on document_attachments
-- Blocks doc attachment to entries in locked periods
-- =============================================================================
CREATE OR REPLACE FUNCTION public.enforce_period_lock_documents()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_is_closed boolean;
v_locked_at timestamptz;
BEGIN
-- Only check if linking to a journal entry
IF NEW.journal_entry_id IS NULL THEN
RETURN NEW;
END IF;
SELECT fp.is_closed, fp.locked_at
INTO v_is_closed, v_locked_at
FROM public.journal_entries je
JOIN public.fiscal_periods fp ON fp.id = je.fiscal_period_id
WHERE je.id = NEW.journal_entry_id;
IF v_is_closed OR v_locked_at IS NOT NULL THEN
RAISE EXCEPTION 'Cannot attach documents to entries in a locked/closed fiscal period';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_period_lock_documents
BEFORE INSERT OR UPDATE ON public.document_attachments
FOR EACH ROW EXECUTE FUNCTION public.enforce_period_lock_documents();
-- =============================================================================
-- 5. block_document_deletion()
-- BEFORE DELETE on document_attachments
-- Blocks deletion if linked to committed entry or within retention window
-- =============================================================================
CREATE OR REPLACE FUNCTION public.block_document_deletion()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_entry_status text;
v_retention_expires date;
BEGIN
-- Check if linked to a committed journal entry
IF OLD.journal_entry_id IS NOT NULL THEN
SELECT je.status INTO v_entry_status
FROM public.journal_entries je
WHERE je.id = OLD.journal_entry_id;
IF v_entry_status IN ('posted', 'reversed') THEN
-- Log the blocked attempt
INSERT INTO public.audit_log (user_id, action, table_name, record_id, description)
VALUES (OLD.user_id, 'DOCUMENT_DELETE_BLOCKED', 'document_attachments', OLD.id,
'Attempted deletion of document linked to ' || v_entry_status || ' journal entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot delete document linked to a % journal entry (Bokföringslagen)',
v_entry_status;
END IF;
END IF;
-- Check retention window
IF OLD.journal_entry_id IS NOT NULL THEN
SELECT fp.retention_expires_at INTO v_retention_expires
FROM public.journal_entries je
JOIN public.fiscal_periods fp ON fp.id = je.fiscal_period_id
WHERE je.id = OLD.journal_entry_id;
IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
INSERT INTO public.audit_log (user_id, action, table_name, record_id, description)
VALUES (OLD.user_id, 'RETENTION_BLOCK', 'document_attachments', OLD.id,
'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
RAISE EXCEPTION 'Cannot delete document within 7-year retention period (expires %)',
v_retention_expires;
END IF;
END IF;
RETURN OLD;
END;
$$;
CREATE TRIGGER block_document_deletion
BEFORE DELETE ON public.document_attachments
FOR EACH ROW EXECUTE FUNCTION public.block_document_deletion();
-- =============================================================================
-- 6. enforce_retention_journal_entries()
-- BEFORE DELETE on journal_entries
-- Blocks deletion within 7-year retention window
-- =============================================================================
CREATE OR REPLACE FUNCTION public.enforce_retention_journal_entries()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_retention_expires date;
BEGIN
SELECT fp.retention_expires_at INTO v_retention_expires
FROM public.fiscal_periods fp
WHERE fp.id = OLD.fiscal_period_id;
IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
INSERT INTO public.audit_log (user_id, action, table_name, record_id, description)
VALUES (OLD.user_id, 'RETENTION_BLOCK', 'journal_entries', OLD.id,
'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
RAISE EXCEPTION 'Cannot delete journal entry within 7-year retention period (expires %)',
v_retention_expires;
END IF;
RETURN OLD;
END;
$$;
-- Note: This trigger must fire BEFORE the immutability trigger so we check retention first
CREATE TRIGGER enforce_retention_journal_entries
BEFORE DELETE ON public.journal_entries
FOR EACH ROW EXECUTE FUNCTION public.enforce_retention_journal_entries();
-- =============================================================================
-- 7. set_committed_at()
-- BEFORE UPDATE on journal_entries
-- Auto-sets committed_at = now() on draft→posted transition
-- =============================================================================
CREATE OR REPLACE FUNCTION public.set_committed_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.status = 'draft' AND NEW.status = 'posted' THEN
NEW.committed_at := now();
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER set_committed_at
BEFORE UPDATE ON public.journal_entries
FOR EACH ROW EXECUTE FUNCTION public.set_committed_at();
-- =============================================================================
-- 8. calculate_retention_expiry()
-- BEFORE INSERT/UPDATE on fiscal_periods
-- Auto-sets retention_expires_at = period_end + 7 years
-- =============================================================================
CREATE OR REPLACE FUNCTION public.calculate_retention_expiry()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.retention_expires_at := NEW.period_end + INTERVAL '7 years';
RETURN NEW;
END;
$$;
CREATE TRIGGER calculate_retention_expiry
BEFORE INSERT OR UPDATE ON public.fiscal_periods
FOR EACH ROW EXECUTE FUNCTION public.calculate_retention_expiry();
-- Backfill existing fiscal periods
UPDATE public.fiscal_periods
SET retention_expires_at = period_end + INTERVAL '7 years'
WHERE retention_expires_at IS NULL;
@@ -0,0 +1,116 @@
-- Migration 18: Audit Logging Triggers
-- Generic audit log writer with AFTER triggers on compliance-critical tables
-- =============================================================================
-- 1. Generic write_audit_log() SECURITY DEFINER function
-- Detects action type from TG_OP and state transitions
-- =============================================================================
CREATE OR REPLACE FUNCTION public.write_audit_log()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_user_id uuid;
v_action text;
v_old_state jsonb;
v_new_state jsonb;
v_record_id uuid;
v_desc text;
BEGIN
-- Determine user_id from the record
IF TG_OP = 'DELETE' THEN
v_user_id := OLD.user_id;
v_record_id := OLD.id;
v_old_state := to_jsonb(OLD);
v_new_state := NULL;
v_action := 'DELETE';
v_desc := 'Deleted ' || TG_TABLE_NAME || ' record';
ELSIF TG_OP = 'INSERT' THEN
v_user_id := NEW.user_id;
v_record_id := NEW.id;
v_old_state := NULL;
v_new_state := to_jsonb(NEW);
v_action := 'INSERT';
v_desc := 'Created ' || TG_TABLE_NAME || ' record';
ELSIF TG_OP = 'UPDATE' THEN
v_user_id := COALESCE(NEW.user_id, OLD.user_id);
v_record_id := COALESCE(NEW.id, OLD.id);
v_old_state := to_jsonb(OLD);
v_new_state := to_jsonb(NEW);
v_action := 'UPDATE';
v_desc := 'Updated ' || TG_TABLE_NAME || ' record';
-- Detect specific state transitions for journal_entries
IF TG_TABLE_NAME = 'journal_entries' THEN
IF OLD.status = 'draft' AND NEW.status = 'posted' THEN
v_action := 'COMMIT';
v_desc := 'Committed journal entry ' || NEW.voucher_series || NEW.voucher_number;
ELSIF OLD.status = 'posted' AND NEW.status = 'reversed' THEN
v_action := 'REVERSE';
v_desc := 'Reversed journal entry ' || OLD.voucher_series || OLD.voucher_number;
END IF;
END IF;
-- Detect period lock/close
IF TG_TABLE_NAME = 'fiscal_periods' THEN
IF (OLD.locked_at IS NULL AND NEW.locked_at IS NOT NULL) THEN
v_action := 'LOCK_PERIOD';
v_desc := 'Locked fiscal period "' || NEW.name || '"';
ELSIF (NOT OLD.is_closed AND NEW.is_closed) THEN
v_action := 'CLOSE_PERIOD';
v_desc := 'Closed fiscal period "' || NEW.name || '"';
END IF;
END IF;
END IF;
-- Write to audit log (bypass RLS via SECURITY DEFINER)
INSERT INTO public.audit_log (user_id, action, table_name, record_id, actor_id, old_state, new_state, description)
VALUES (v_user_id, v_action, TG_TABLE_NAME, v_record_id, v_user_id, v_old_state, v_new_state, v_desc);
-- Return appropriate value
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
-- =============================================================================
-- 2. AFTER triggers on compliance-critical tables
-- =============================================================================
-- journal_entries
CREATE TRIGGER audit_journal_entries
AFTER INSERT OR UPDATE OR DELETE ON public.journal_entries
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- journal_entry_lines
CREATE TRIGGER audit_journal_entry_lines
AFTER INSERT OR UPDATE OR DELETE ON public.journal_entry_lines
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- chart_of_accounts
CREATE TRIGGER audit_chart_of_accounts
AFTER INSERT OR UPDATE OR DELETE ON public.chart_of_accounts
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- document_attachments
CREATE TRIGGER audit_document_attachments
AFTER INSERT OR UPDATE OR DELETE ON public.document_attachments
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- fiscal_periods
CREATE TRIGGER audit_fiscal_periods
AFTER INSERT OR UPDATE OR DELETE ON public.fiscal_periods
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- company_settings
CREATE TRIGGER audit_company_settings
AFTER INSERT OR UPDATE OR DELETE ON public.company_settings
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- tax_codes
CREATE TRIGGER audit_tax_codes
AFTER INSERT OR UPDATE OR DELETE ON public.tax_codes
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
@@ -0,0 +1,58 @@
-- Migration 19: Fiscal Period Closing Metadata
-- Adds columns for year-end closing workflow and opening balance tracking
-- =============================================================================
-- 1. Add closing_entry_id — tracks which journal entry closed this period
-- =============================================================================
ALTER TABLE public.fiscal_periods
ADD COLUMN IF NOT EXISTS closing_entry_id uuid REFERENCES public.journal_entries(id);
-- =============================================================================
-- 2. Add opening_balance_entry_id — tracks which entry set opening balances
-- =============================================================================
ALTER TABLE public.fiscal_periods
ADD COLUMN IF NOT EXISTS opening_balance_entry_id uuid REFERENCES public.journal_entries(id);
-- =============================================================================
-- 3. Add previous_period_id — chain validation link
-- =============================================================================
ALTER TABLE public.fiscal_periods
ADD COLUMN IF NOT EXISTS previous_period_id uuid REFERENCES public.fiscal_periods(id);
-- =============================================================================
-- 4. Trigger: block modification of opening balance entries
-- Once a fiscal period has opening_balance_entry_id set and the entry is posted,
-- the opening_balance_entry_id cannot be changed.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.enforce_opening_balance_immutability()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Only check if opening_balance_entry_id is being changed
IF OLD.opening_balance_entry_id IS NOT NULL
AND OLD.opening_balances_set = true
AND NEW.opening_balance_entry_id IS DISTINCT FROM OLD.opening_balance_entry_id THEN
RAISE EXCEPTION 'Cannot modify opening_balance_entry_id on period "%" — opening balances are immutable once set',
OLD.name;
END IF;
-- Also block changing closing_entry_id once set
IF OLD.closing_entry_id IS NOT NULL
AND NEW.closing_entry_id IS DISTINCT FROM OLD.closing_entry_id THEN
RAISE EXCEPTION 'Cannot modify closing_entry_id on period "%" — year-end closing is immutable',
OLD.name;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_opening_balance_immutability
BEFORE UPDATE ON public.fiscal_periods
FOR EACH ROW EXECUTE FUNCTION public.enforce_opening_balance_immutability();
-- Indexes for the new FK columns
CREATE INDEX IF NOT EXISTS idx_fiscal_periods_closing_entry ON public.fiscal_periods (closing_entry_id);
CREATE INDEX IF NOT EXISTS idx_fiscal_periods_opening_balance_entry ON public.fiscal_periods (opening_balance_entry_id);
CREATE INDEX IF NOT EXISTS idx_fiscal_periods_previous_period ON public.fiscal_periods (previous_period_id);
@@ -0,0 +1,64 @@
-- ============================================================
-- Extension Data & Event Log Tables
-- Part 3: Event Bus & Extension Registry
-- ============================================================
-- Generic key-value store for extensions
create table if not exists extension_data (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null,
extension_id text not null,
key text not null,
value jsonb not null default '{}',
created_at timestamptz default now(),
updated_at timestamptz default now(),
unique(user_id, extension_id, key)
);
-- RLS: users can only access their own extension data
alter table extension_data enable row level security;
create policy "Users can select own extension data"
on extension_data for select
using (auth.uid() = user_id);
create policy "Users can insert own extension data"
on extension_data for insert
with check (auth.uid() = user_id);
create policy "Users can update own extension data"
on extension_data for update
using (auth.uid() = user_id);
create policy "Users can delete own extension data"
on extension_data for delete
using (auth.uid() = user_id);
-- Auto-update updated_at
create trigger extension_data_updated_at
before update on extension_data
for each row execute function update_updated_at();
-- Append-only event log for observability
create table if not exists event_log (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null,
event_type text not null,
payload jsonb not null default '{}',
created_at timestamptz default now()
);
-- RLS: users can select and insert only (no update, no delete)
alter table event_log enable row level security;
create policy "Users can select own event log"
on event_log for select
using (auth.uid() = user_id);
create policy "Users can insert own event log"
on event_log for insert
with check (auth.uid() = user_id);
-- Index for querying by event type
create index if not exists idx_event_log_user_type on event_log (user_id, event_type);
create index if not exists idx_event_log_created_at on event_log (created_at);