199 lines
7.1 KiB
PL/PgSQL
199 lines
7.1 KiB
PL/PgSQL
/*
|
|
* -------------------------------------------------------
|
|
* 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;
|