-- ===================================================== -- SEPA Data Deduplication (Phase 1) -- -- Problem: members table has inline SEPA fields (iban, bic, -- account_holder, sepa_mandate_id, sepa_mandate_date, -- sepa_mandate_status, sepa_mandate_sequence, sepa_bank_name) -- AND a separate sepa_mandates table. sepa_mandate_id is text, -- not a FK to sepa_mandates(id) which is uuid. Data diverges. -- -- Fix: Add proper primary_mandate_id FK, migrate inline data -- to sepa_mandates rows, rewrite RPCs to read from sepa_mandates. -- Inline columns are kept read-only for backward compat (phase 2 drops them). -- ===================================================== -- Step 1: Add proper FK column pointing to the primary mandate ALTER TABLE public.members ADD COLUMN IF NOT EXISTS primary_mandate_id uuid REFERENCES public.sepa_mandates(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS ix_members_primary_mandate ON public.members(primary_mandate_id) WHERE primary_mandate_id IS NOT NULL; -- Step 2: For members with inline SEPA data but no sepa_mandates row, create one DO $$ DECLARE r record; v_mandate_id uuid; BEGIN FOR r IN SELECT m.id AS member_id, m.account_id, m.iban, m.bic, m.account_holder, m.first_name, m.last_name, m.sepa_mandate_id, m.sepa_mandate_date, m.sepa_mandate_status, m.sepa_mandate_reference, m.sepa_mandate_sequence, m.sepa_bank_name FROM public.members m WHERE m.iban IS NOT NULL AND m.iban != '' AND NOT EXISTS ( SELECT 1 FROM public.sepa_mandates sm WHERE sm.member_id = m.id ) LOOP INSERT INTO public.sepa_mandates ( member_id, account_id, mandate_reference, iban, bic, account_holder, mandate_date, status, sequence, is_primary, notes ) VALUES ( r.member_id, r.account_id, COALESCE(NULLIF(r.sepa_mandate_reference, ''), NULLIF(r.sepa_mandate_id, ''), 'MIGRATED-' || r.member_id::text), r.iban, r.bic, COALESCE(NULLIF(r.account_holder, ''), NULLIF(TRIM(COALESCE(r.first_name, '') || ' ' || COALESCE(r.last_name, '')), ''), 'Unbekannt'), COALESCE(r.sepa_mandate_date, current_date), COALESCE(r.sepa_mandate_status, 'pending'::public.sepa_mandate_status), COALESCE(NULLIF(r.sepa_mandate_sequence, ''), 'RCUR'), true, CASE WHEN r.sepa_bank_name IS NOT NULL AND r.sepa_bank_name != '' THEN 'Bank: ' || r.sepa_bank_name ELSE NULL END ) RETURNING id INTO v_mandate_id; UPDATE public.members SET primary_mandate_id = v_mandate_id WHERE id = r.member_id; END LOOP; END $$; -- Step 3: For members that already have sepa_mandates rows, link the primary one UPDATE public.members m SET primary_mandate_id = sm.id FROM public.sepa_mandates sm WHERE sm.member_id = m.id AND sm.is_primary = true AND m.primary_mandate_id IS NULL; -- If no mandate marked as primary, pick the most recent active one UPDATE public.members m SET primary_mandate_id = ( SELECT sm.id FROM public.sepa_mandates sm WHERE sm.member_id = m.id ORDER BY CASE WHEN sm.status = 'active' THEN 0 ELSE 1 END, sm.created_at DESC LIMIT 1 ) WHERE m.primary_mandate_id IS NULL AND EXISTS (SELECT 1 FROM public.sepa_mandates sm WHERE sm.member_id = m.id); -- Step 4: Rewrite list_hierarchy_sepa_eligible_members to read from sepa_mandates CREATE OR REPLACE FUNCTION public.list_hierarchy_sepa_eligible_members( root_account_id uuid, p_account_filter uuid DEFAULT NULL ) RETURNS TABLE ( member_id uuid, account_id uuid, account_name varchar, first_name text, last_name text, iban text, bic text, account_holder text, mandate_id text, mandate_date date, dues_amount numeric ) LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN IF NOT public.has_role_on_account(root_account_id) THEN RETURN; END IF; RETURN QUERY SELECT m.id AS member_id, m.account_id, a.name AS account_name, m.first_name, m.last_name, sm.iban, sm.bic, sm.account_holder, sm.mandate_reference AS mandate_id, sm.mandate_date, COALESCE(dc.amount, 0) AS dues_amount FROM public.members m JOIN public.accounts a ON a.id = m.account_id JOIN public.sepa_mandates sm ON sm.id = m.primary_mandate_id LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id WHERE m.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d) AND m.status = 'active' AND sm.iban IS NOT NULL AND sm.status = 'active' AND (p_account_filter IS NULL OR m.account_id = p_account_filter) ORDER BY a.name, m.last_name, m.first_name; END; $$; -- Step 5: Add partial index for fast SEPA-eligible lookups CREATE INDEX IF NOT EXISTS ix_sepa_mandates_active_primary ON public.sepa_mandates(member_id) WHERE status = 'active' AND is_primary = true; -- Note: Inline SEPA columns (iban, bic, account_holder, sepa_mandate_id, -- sepa_mandate_date, sepa_mandate_status, sepa_mandate_sequence, sepa_bank_name) -- are kept for read-only backward compatibility. Phase 2 migration will drop them -- after all code paths are migrated to use sepa_mandates via primary_mandate_id.