/* * ------------------------------------------------------- * Member Transfer Between Accounts * * Enables transferring a member from one Verein to another * within the same Verband hierarchy. * * Design: * - Personal data always moves with the member * - Course enrollments, event registrations, bookings are * linked via member_id (not account_id) so they survive * the transfer automatically * - Only org-specific admin data is cleared: * dues_category_id (org-specific pricing), * member_number (unique per org) * - SEPA bank data (IBAN/BIC) is preserved, but mandate * status resets to 'pending' (needs re-confirmation) * - Financial records (invoices, SEPA batches) stay in * source org — they're legally tied to that entity * ------------------------------------------------------- */ -- Transfer log table CREATE TABLE IF NOT EXISTS public.member_transfers ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, source_account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE SET NULL, target_account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE SET NULL, transferred_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL, reason text, -- Snapshot what was cleared so it can be reviewed later cleared_data jsonb NOT NULL DEFAULT '{}'::jsonb, transferred_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS ix_member_transfers_member ON public.member_transfers(member_id); CREATE INDEX IF NOT EXISTS ix_member_transfers_source ON public.member_transfers(source_account_id); CREATE INDEX IF NOT EXISTS ix_member_transfers_target ON public.member_transfers(target_account_id); ALTER TABLE public.member_transfers ENABLE ROW LEVEL SECURITY; REVOKE ALL ON public.member_transfers FROM authenticated, service_role; GRANT SELECT, INSERT ON public.member_transfers TO authenticated; GRANT ALL ON public.member_transfers TO service_role; -- Readable by members of source or target account (or ancestor via hierarchy) CREATE POLICY member_transfers_read ON public.member_transfers FOR SELECT TO authenticated USING ( public.has_role_on_account_or_ancestor(source_account_id) OR public.has_role_on_account_or_ancestor(target_account_id) ); -- ------------------------------------------------------- -- Transfer function -- -- Only clears org-specific admin data. All cross-org -- relationships (courses, events, bookings) survive because -- they reference member_id, not account_id. -- ------------------------------------------------------- CREATE OR REPLACE FUNCTION public.transfer_member( p_member_id uuid, p_target_account_id uuid, p_reason text DEFAULT NULL, p_keep_sepa boolean DEFAULT true ) RETURNS uuid -- returns the transfer log ID LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ DECLARE v_source_account_id uuid; v_transfer_id uuid; v_caller_id uuid; v_old_member_number text; v_old_dues_category_id uuid; v_old_sepa_mandate_id text; v_source_name text; v_target_name text; v_active_courses int; v_active_events int; BEGIN v_caller_id := (SELECT auth.uid()); -- Get the member's current account and data we'll clear SELECT account_id, member_number, dues_category_id, sepa_mandate_id INTO v_source_account_id, v_old_member_number, v_old_dues_category_id, v_old_sepa_mandate_id FROM public.members WHERE id = p_member_id; IF v_source_account_id IS NULL THEN RAISE EXCEPTION 'Mitglied nicht gefunden'; END IF; IF v_source_account_id = p_target_account_id THEN RAISE EXCEPTION 'Mitglied ist bereits in dieser Organisation'; END IF; -- Target must be a team account IF NOT EXISTS ( SELECT 1 FROM public.accounts WHERE id = p_target_account_id AND is_personal_account = false ) THEN RAISE EXCEPTION 'Zielorganisation nicht gefunden'; END IF; -- Verify caller has visibility on BOTH accounts via hierarchy IF NOT ( public.has_role_on_account_or_ancestor(v_source_account_id) AND public.has_role_on_account_or_ancestor(p_target_account_id) ) THEN RAISE EXCEPTION 'Keine Berechtigung für den Transfer'; END IF; -- Verify both accounts share a common ancestor (same Verband) IF NOT EXISTS ( SELECT 1 FROM public.get_account_ancestors(v_source_account_id) sa JOIN public.get_account_ancestors(p_target_account_id) ta ON sa = ta ) THEN RAISE EXCEPTION 'Organisationen gehören nicht zum selben Verband'; END IF; -- Get org names for the transfer note SELECT name INTO v_source_name FROM public.accounts WHERE id = v_source_account_id; SELECT name INTO v_target_name FROM public.accounts WHERE id = p_target_account_id; -- Count active relationships (informational, for the log) SELECT count(*) INTO v_active_courses FROM public.course_participants cp JOIN public.courses c ON c.id = cp.course_id WHERE cp.member_id = p_member_id AND cp.status = 'enrolled'; SELECT count(*) INTO v_active_events FROM public.event_registrations er JOIN public.events e ON e.id = er.event_id WHERE er.email = (SELECT email FROM public.members WHERE id = p_member_id) AND er.status IN ('confirmed', 'pending') AND e.event_date >= current_date; -- Perform the transfer UPDATE public.members SET account_id = p_target_account_id, -- Clear org-specific admin data dues_category_id = NULL, member_number = NULL, -- SEPA: keep bank data (IBAN/BIC/account_holder), just reset mandate status sepa_mandate_id = CASE WHEN p_keep_sepa THEN sepa_mandate_id ELSE NULL END, sepa_mandate_date = CASE WHEN p_keep_sepa THEN sepa_mandate_date ELSE NULL END, sepa_mandate_status = 'pending', -- always needs re-confirmation in new org -- Append transfer note notes = COALESCE(notes, '') || E'\n[Transfer ' || now()::date || '] ' || v_source_name || ' → ' || v_target_name || COALESCE(E' — ' || p_reason, '') || CASE WHEN v_active_courses > 0 THEN E'\n ↳ ' || v_active_courses || ' aktive Kurseinschreibungen bleiben erhalten' ELSE '' END || CASE WHEN v_active_events > 0 THEN E'\n ↳ ' || v_active_events || ' aktive Veranstaltungsanmeldungen bleiben erhalten' ELSE '' END, updated_by = v_caller_id, updated_at = now() WHERE id = p_member_id; -- Log the transfer with snapshot of cleared data INSERT INTO public.member_transfers ( member_id, source_account_id, target_account_id, transferred_by, reason, cleared_data ) VALUES ( p_member_id, v_source_account_id, p_target_account_id, v_caller_id, p_reason, jsonb_build_object( 'old_member_number', v_old_member_number, 'old_dues_category_id', v_old_dues_category_id, 'old_sepa_mandate_id', v_old_sepa_mandate_id, 'active_courses_at_transfer', v_active_courses, 'active_events_at_transfer', v_active_events, 'sepa_kept', p_keep_sepa ) ) RETURNING id INTO v_transfer_id; RETURN v_transfer_id; END; $$; GRANT EXECUTE ON FUNCTION public.transfer_member(uuid, uuid, text, boolean) TO authenticated, service_role;