Files
accounted/supabase/migrations/20240101000016_voucher_sequences.sql
T
Jakob Wennberg cdf1dcc4c8 New Base func
2026-02-19 09:48:02 +01:00

154 lines
5.0 KiB
PL/PgSQL

-- 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;