154 lines
5.0 KiB
PL/PgSQL
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;
|