New Base func
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user