-- ============================================================================= -- AgenciaPsi — Functions — Plans, Subscriptions, Billing -- ============================================================================= -- activate_subscription_from_intent, cancel_subscription, -- change_subscription_plan, toggle_plan, transition_subscription, -- reactivate_subscription, rebuild_owner_entitlements, -- fix_all_subscription_mismatches, subscription_intents_view_insert, -- subscriptions_validate_scope, admin_fix_plan_target, -- set_tenant_feature_exception, guard_no_change_core_plan_key, etc. -- ============================================================================= CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions LANGUAGE plpgsql SECURITY DEFINER AS $$ declare v_intent record; v_sub public.subscriptions; v_days int; v_user_id uuid; v_plan_id uuid; v_target text; begin -- lê pela VIEW unificada select * into v_intent from public.subscription_intents where id = p_intent_id; if not found then raise exception 'Intent não encontrado: %', p_intent_id; end if; if v_intent.status <> 'paid' then raise exception 'Intent precisa estar paid para ativar assinatura'; end if; -- resolve target e plan_id via plans.key select p.id, p.target into v_plan_id, v_target from public.plans p where p.key = v_intent.plan_key limit 1; if v_plan_id is null then raise exception 'Plano não encontrado em plans.key = %', v_intent.plan_key; end if; v_target := lower(coalesce(v_target, '')); -- ✅ supervisor adicionado if v_target not in ('clinic', 'therapist', 'supervisor') then raise exception 'Target inválido em plans.target: %', v_target; end if; -- regra por target if v_target = 'clinic' then if v_intent.tenant_id is null then raise exception 'Intent sem tenant_id'; end if; else -- therapist ou supervisor: vinculado ao user v_user_id := v_intent.user_id; if v_user_id is null then v_user_id := v_intent.created_by_user_id; end if; end if; if v_target in ('therapist', 'supervisor') and v_user_id is null then raise exception 'Não foi possível determinar user_id para assinatura %.', v_target; end if; -- cancela assinatura ativa anterior if v_target = 'clinic' then update public.subscriptions set status = 'cancelled', cancelled_at = now() where tenant_id = v_intent.tenant_id and plan_id = v_plan_id and status = 'active'; else -- therapist ou supervisor update public.subscriptions set status = 'cancelled', cancelled_at = now() where user_id = v_user_id and plan_id = v_plan_id and status = 'active' and tenant_id is null; end if; -- duração do plano (30 dias para mensal) v_days := case when lower(coalesce(v_intent.interval, 'month')) = 'year' then 365 else 30 end; -- cria nova assinatura insert into public.subscriptions ( user_id, plan_id, status, started_at, expires_at, cancelled_at, activated_at, tenant_id, plan_key, interval, source, created_at, updated_at ) values ( case when v_target = 'clinic' then null else v_user_id end, v_plan_id, 'active', now(), now() + make_interval(days => v_days), null, now(), case when v_target = 'clinic' then v_intent.tenant_id else null end, v_intent.plan_key, v_intent.interval, 'manual', now(), now() ) returning * into v_sub; -- grava vínculo intent → subscription if v_target = 'clinic' then update public.subscription_intents_tenant set subscription_id = v_sub.id where id = p_intent_id; else update public.subscription_intents_personal set subscription_id = v_sub.id where id = p_intent_id; end if; return v_sub; end; $$; ALTER FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) OWNER TO supabase_admin; -- -- Name: admin_credit_addon(uuid, text, integer, uuid, text, text, integer); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Crédito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Crédito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $$ DECLARE v_credit addon_credits%ROWTYPE; v_balance_before INTEGER; v_balance_after INTEGER; v_tx_id UUID; BEGIN -- Upsert addon_credits INSERT INTO addon_credits (tenant_id, addon_type, balance, total_purchased) VALUES (p_tenant_id, p_addon_type, 0, 0) ON CONFLICT (tenant_id, addon_type) DO NOTHING; -- Lock e leitura SELECT * INTO v_credit FROM addon_credits WHERE tenant_id = p_tenant_id AND addon_type = p_addon_type FOR UPDATE; v_balance_before := v_credit.balance; v_balance_after := v_credit.balance + p_amount; -- Atualiza saldo UPDATE addon_credits SET balance = v_balance_after, total_purchased = total_purchased + p_amount, low_balance_notified = CASE WHEN v_balance_after > COALESCE(low_balance_threshold, 10) THEN false ELSE low_balance_notified END, updated_at = now() WHERE id = v_credit.id; -- Registra transação INSERT INTO addon_transactions ( tenant_id, addon_type, type, amount, balance_before, balance_after, product_id, description, admin_user_id, payment_method, price_cents ) VALUES ( p_tenant_id, p_addon_type, 'purchase', p_amount, v_balance_before, v_balance_after, p_product_id, p_description, auth.uid(), p_payment_method, p_price_cents ) RETURNING id INTO v_tx_id; RETURN jsonb_build_object( 'success', true, 'transaction_id', v_tx_id, 'balance_before', v_balance_before, 'balance_after', v_balance_after ); END; $$; ALTER FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid, p_description text, p_payment_method text, p_price_cents integer) OWNER TO supabase_admin; -- -- Name: FUNCTION admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid, p_description text, p_payment_method text, p_price_cents integer); Type: COMMENT; Schema: public; Owner: supabase_admin -- COMMENT ON FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid, p_description text, p_payment_method text, p_price_cents integer) IS 'Admin adiciona créditos de add-on a um tenant. Cria registro se não existir.'; -- -- Name: admin_delete_email_template_global(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.admin_delete_email_template_global(p_id uuid) RETURNS boolean CREATE FUNCTION public.admin_delete_email_template_global(p_id uuid) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $$ BEGIN DELETE FROM public.email_templates_global WHERE id = p_id; IF NOT FOUND THEN RAISE EXCEPTION 'Template com id % não encontrado', p_id; END IF; RETURN true; END; $$; ALTER FUNCTION public.admin_delete_email_template_global(p_id uuid) OWNER TO supabase_admin; -- -- Name: admin_fix_plan_target(text, text); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.admin_fix_plan_target(p_plan_key text, p_new_target text) RETURNS void CREATE FUNCTION public.admin_fix_plan_target(p_plan_key text, p_new_target text) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ declare v_plan_id uuid; begin -- (opcional) restringe targets válidos if p_new_target not in ('clinic','therapist') then raise exception 'Target inválido: %', p_new_target using errcode='P0001'; end if; -- trava o plano select id into v_plan_id from public.plans where key = p_plan_key for update; if v_plan_id is null then raise exception 'Plano não encontrado: %', p_plan_key using errcode='P0001'; end if; -- segurança: não mexer se existe subscription if exists (select 1 from public.subscriptions s where s.plan_id = v_plan_id) then raise exception 'Plano % possui subscriptions. Migração bloqueada.', p_plan_key using errcode='P0001'; end if; -- liga bypass SOMENTE nesta transação perform set_config('app.plan_migration_bypass', '1', true); update public.plans set target = p_new_target where id = v_plan_id; end $$; ALTER FUNCTION public.admin_fix_plan_target(p_plan_key text, p_new_target text) OWNER TO supabase_admin; -- -- Name: admin_upsert_email_template_global(uuid, text, text, text, text, text, text, boolean, jsonb); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.admin_upsert_email_template_global(p_id uuid DEFAULT NULL::uuid, p_key text DEFAULT NULL::text, p_domain text DEFAULT NULL::text, p_channel text DEFAULT 'email'::text, p_subject text DEFAULT NULL::text, p_body_html text DEFAULT NULL::text, p_body_text text DEFAULT NULL::text, p_is_active boolean DEFAULT true, p_variables jsonb DEFAULT '{}'::jsonb) RETURNS jsonb CREATE FUNCTION public.admin_upsert_email_template_global(p_id uuid DEFAULT NULL::uuid, p_key text DEFAULT NULL::text, p_domain text DEFAULT NULL::text, p_channel text DEFAULT 'email'::text, p_subject text DEFAULT NULL::text, p_body_html text DEFAULT NULL::text, p_body_text text DEFAULT NULL::text, p_is_active boolean DEFAULT true, p_variables jsonb DEFAULT '{}'::jsonb) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $$ DECLARE v_result jsonb; v_id uuid; BEGIN -- UPDATE existente IF p_id IS NOT NULL THEN UPDATE public.email_templates_global SET subject = COALESCE(p_subject, subject), body_html = COALESCE(p_body_html, body_html), body_text = p_body_text, is_active = p_is_active, variables = COALESCE(p_variables, variables), version = version + 1 WHERE id = p_id RETURNING to_jsonb(email_templates_global.*) INTO v_result; IF v_result IS NULL THEN RAISE EXCEPTION 'Template com id % não encontrado', p_id; END IF; RETURN v_result; END IF; -- INSERT novo IF p_key IS NULL OR p_domain IS NULL OR p_subject IS NULL OR p_body_html IS NULL THEN RAISE EXCEPTION 'key, domain, subject e body_html são obrigatórios para novo template'; END IF; INSERT INTO public.email_templates_global (key, domain, channel, subject, body_html, body_text, is_active, variables) VALUES (p_key, p_domain, p_channel, p_subject, p_body_html, p_body_text, p_is_active, p_variables) RETURNING to_jsonb(email_templates_global.*) INTO v_result; RETURN v_result; END; $$; ALTER FUNCTION public.admin_upsert_email_template_global(p_id uuid, p_key text, p_domain text, p_channel text, p_subject text, p_body_html text, p_body_text text, p_is_active boolean, p_variables jsonb) OWNER TO supabase_admin; -- -- Name: agenda_cfg_sync(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.agenda_cfg_sync() RETURNS trigger CREATE FUNCTION public.cancel_subscription(p_subscription_id uuid) RETURNS public.subscriptions LANGUAGE plpgsql SECURITY DEFINER AS $$ declare v_sub public.subscriptions; v_owner_type text; v_owner_ref uuid; begin select * into v_sub from public.subscriptions where id = p_subscription_id for update; if not found then raise exception 'Subscription não encontrada'; end if; if v_sub.status = 'canceled' then return v_sub; end if; if v_sub.tenant_id is not null then v_owner_type := 'clinic'; v_owner_ref := v_sub.tenant_id; elsif v_sub.user_id is not null then v_owner_type := 'therapist'; v_owner_ref := v_sub.user_id; else v_owner_type := null; v_owner_ref := null; end if; update public.subscriptions set status = 'canceled', cancel_at_period_end = false, updated_at = now() where id = p_subscription_id returning * into v_sub; insert into public.subscription_events( subscription_id, owner_id, owner_type, owner_ref, event_type, old_plan_id, new_plan_id, created_by, reason, source, metadata ) values ( v_sub.id, v_owner_ref, v_owner_type, v_owner_ref, 'canceled', v_sub.plan_id, v_sub.plan_id, auth.uid(), 'Cancelamento manual via admin', 'admin_panel', jsonb_build_object('previous_status', 'active') ); if v_owner_ref is not null then insert into public.entitlements_invalidation(owner_id, changed_at) values (v_owner_ref, now()) on conflict (owner_id) do update set changed_at = excluded.changed_at; end if; return v_sub; end; $$; ALTER FUNCTION public.cancel_subscription(p_subscription_id uuid) OWNER TO supabase_admin; -- -- Name: cancelar_eventos_serie(uuid, timestamp with time zone); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone DEFAULT now()) RETURNS integer CREATE FUNCTION public.change_subscription_plan(p_subscription_id uuid, p_new_plan_id uuid) RETURNS public.subscriptions LANGUAGE plpgsql SECURITY DEFINER AS $$ declare v_sub public.subscriptions; v_old_plan uuid; v_new_key text; v_owner_type text; v_owner_ref uuid; v_new_target text; v_sub_target text; begin select * into v_sub from public.subscriptions where id = p_subscription_id for update; if not found then raise exception 'Subscription não encontrada'; end if; v_old_plan := v_sub.plan_id; if v_old_plan = p_new_plan_id then return v_sub; end if; select key, target into v_new_key, v_new_target from public.plans where id = p_new_plan_id; if v_new_key is null then raise exception 'Plano não encontrado'; end if; v_new_target := lower(coalesce(v_new_target, '')); v_sub_target := case when v_sub.tenant_id is not null then 'clinic' else 'therapist' end; if v_new_target <> v_sub_target then raise exception 'Plano inválido para este tipo de assinatura. Assinatura é % e o plano é %.', v_sub_target, v_new_target using errcode = 'P0001'; end if; if v_sub.tenant_id is not null then v_owner_type := 'clinic'; v_owner_ref := v_sub.tenant_id; elsif v_sub.user_id is not null then v_owner_type := 'therapist'; v_owner_ref := v_sub.user_id; else v_owner_type := null; v_owner_ref := null; end if; update public.subscriptions set plan_id = p_new_plan_id, plan_key = v_new_key, updated_at = now() where id = p_subscription_id returning * into v_sub; insert into public.subscription_events( subscription_id, owner_id, owner_type, owner_ref, event_type, old_plan_id, new_plan_id, created_by, reason, source, metadata ) values ( v_sub.id, v_owner_ref, v_owner_type, v_owner_ref, 'plan_changed', v_old_plan, p_new_plan_id, auth.uid(), 'Plan change via DEV menu', 'dev_menu', jsonb_build_object( 'previous_plan', v_old_plan, 'new_plan', p_new_plan_id, 'new_plan_key', v_new_key, 'new_plan_target', v_new_target ) ); if v_owner_ref is not null then insert into public.entitlements_invalidation (owner_id, changed_at) values (v_owner_ref, now()) on conflict (owner_id) do update set changed_at = excluded.changed_at; end if; return v_sub; end; $$; ALTER FUNCTION public.change_subscription_plan(p_subscription_id uuid, p_new_plan_id uuid) OWNER TO supabase_admin; -- -- Name: cleanup_notification_queue(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.cleanup_notification_queue() RETURNS integer CREATE FUNCTION public.debit_addon_credit(p_tenant_id uuid, p_addon_type text, p_queue_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Consumo'::text) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $$ DECLARE v_credit addon_credits%ROWTYPE; v_balance_before INTEGER; v_balance_after INTEGER; BEGIN -- Lock e leitura SELECT * INTO v_credit FROM addon_credits WHERE tenant_id = p_tenant_id AND addon_type = p_addon_type AND is_active = true FOR UPDATE; IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'reason', 'no_credits', 'balance', 0); END IF; -- Verifica saldo IF v_credit.balance <= 0 THEN RETURN jsonb_build_object('success', false, 'reason', 'insufficient_balance', 'balance', 0); END IF; -- Verifica rate limit diário IF v_credit.daily_limit IS NOT NULL THEN -- Reset se passou do dia IF v_credit.daily_reset_at IS NULL OR v_credit.daily_reset_at < date_trunc('day', now()) THEN UPDATE addon_credits SET daily_used = 0, daily_reset_at = date_trunc('day', now()) + interval '1 day' WHERE id = v_credit.id; v_credit.daily_used := 0; END IF; IF v_credit.daily_used >= v_credit.daily_limit THEN RETURN jsonb_build_object('success', false, 'reason', 'daily_limit_reached', 'balance', v_credit.balance); END IF; END IF; -- Verifica rate limit horário IF v_credit.hourly_limit IS NOT NULL THEN IF v_credit.hourly_reset_at IS NULL OR v_credit.hourly_reset_at < date_trunc('hour', now()) THEN UPDATE addon_credits SET hourly_used = 0, hourly_reset_at = date_trunc('hour', now()) + interval '1 hour' WHERE id = v_credit.id; v_credit.hourly_used := 0; END IF; IF v_credit.hourly_used >= v_credit.hourly_limit THEN RETURN jsonb_build_object('success', false, 'reason', 'hourly_limit_reached', 'balance', v_credit.balance); END IF; END IF; -- Verifica expiração IF v_credit.expires_at IS NOT NULL AND v_credit.expires_at < now() THEN RETURN jsonb_build_object('success', false, 'reason', 'credits_expired', 'balance', v_credit.balance); END IF; v_balance_before := v_credit.balance; v_balance_after := v_credit.balance - 1; -- Debita UPDATE addon_credits SET balance = v_balance_after, total_consumed = total_consumed + 1, daily_used = COALESCE(daily_used, 0) + 1, hourly_used = COALESCE(hourly_used, 0) + 1, updated_at = now() WHERE id = v_credit.id; -- Registra transação INSERT INTO addon_transactions ( tenant_id, addon_type, type, amount, balance_before, balance_after, queue_id, description ) VALUES ( p_tenant_id, p_addon_type, 'consume', -1, v_balance_before, v_balance_after, p_queue_id, p_description ); RETURN jsonb_build_object( 'success', true, 'balance_before', v_balance_before, 'balance_after', v_balance_after ); END; $$; ALTER FUNCTION public.debit_addon_credit(p_tenant_id uuid, p_addon_type text, p_queue_id uuid, p_description text) OWNER TO supabase_admin; -- -- Name: FUNCTION debit_addon_credit(p_tenant_id uuid, p_addon_type text, p_queue_id uuid, p_description text); Type: COMMENT; Schema: public; Owner: supabase_admin -- COMMENT ON FUNCTION public.debit_addon_credit(p_tenant_id uuid, p_addon_type text, p_queue_id uuid, p_description text) IS 'Debita 1 crédito de add-on. Verifica saldo, rate limits e expiração.'; -- -- Name: delete_commitment_full(uuid, uuid); Type: FUNCTION; Schema: public; Owner: postgres -- CREATE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ declare r record; begin for r in select distinct s.user_id as owner_id from public.subscriptions s where s.status = 'active' and s.user_id is not null loop perform public.rebuild_owner_entitlements(r.owner_id); end loop; end; $$; ALTER FUNCTION public.fix_all_subscription_mismatches() OWNER TO supabase_admin; -- -- Name: fn_agenda_regras_semanais_no_overlap(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.fn_agenda_regras_semanais_no_overlap() RETURNS trigger LANGUAGE plpgsql AS $$ declare v_count int; begin if new.ativo is false then return new; end if; select count(*) into v_count from public.agenda_regras_semanais r where r.owner_id = new.owner_id and r.dia_semana = new.dia_semana and r.ativo is true and (tg_op = 'INSERT' or r.id <> new.id) and (new.hora_inicio < r.hora_fim and new.hora_fim > r.hora_inicio); if v_count > 0 then raise exception 'Janela sobreposta: já existe uma regra ativa nesse intervalo.'; end if; return new; end; $$; ALTER FUNCTION public.fn_agenda_regras_semanais_no_overlap() OWNER TO supabase_admin; -- -- Name: get_financial_report(uuid, date, date, text); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month'::text) RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint) CREATE FUNCTION public.guard_no_change_core_plan_key() RETURNS trigger LANGUAGE plpgsql AS $$ begin if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro') and new.key is distinct from old.key then raise exception 'Não é permitido alterar a key do plano padrão (%).', old.key using errcode = 'P0001'; end if; return new; end $$; ALTER FUNCTION public.guard_no_change_core_plan_key() OWNER TO supabase_admin; -- -- Name: guard_no_change_plan_target(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.guard_no_change_plan_target() RETURNS trigger LANGUAGE plpgsql AS $$ declare v_bypass text; begin -- bypass controlado por sessão/transação: -- só passa se app.plan_migration_bypass = '1' v_bypass := current_setting('app.plan_migration_bypass', true); if v_bypass = '1' then return new; end if; -- comportamento original (bloqueia qualquer mudança) if new.target is distinct from old.target then raise exception 'Não é permitido alterar target do plano (%) de % para %.', old.key, old.target, new.target using errcode = 'P0001'; end if; return new; end $$; ALTER FUNCTION public.guard_no_change_plan_target() OWNER TO supabase_admin; -- -- Name: guard_no_delete_core_plans(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.guard_no_delete_core_plans() RETURNS trigger CREATE FUNCTION public.guard_no_change_plan_target() RETURNS trigger LANGUAGE plpgsql AS $$ declare v_bypass text; begin -- bypass controlado por sessão/transação: -- só passa se app.plan_migration_bypass = '1' v_bypass := current_setting('app.plan_migration_bypass', true); if v_bypass = '1' then return new; end if; -- comportamento original (bloqueia qualquer mudança) if new.target is distinct from old.target then raise exception 'Não é permitido alterar target do plano (%) de % para %.', old.key, old.target, new.target using errcode = 'P0001'; end if; return new; end $$; ALTER FUNCTION public.guard_no_change_plan_target() OWNER TO supabase_admin; -- -- Name: guard_no_delete_core_plans(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.guard_no_delete_core_plans() RETURNS trigger LANGUAGE plpgsql AS $$ begin if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro') then raise exception 'Plano padrão (%) não pode ser removido.', old.key using errcode = 'P0001'; end if; return old; end $$; ALTER FUNCTION public.guard_no_delete_core_plans() OWNER TO supabase_admin; -- -- Name: guard_patient_cannot_own_tenant(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.guard_patient_cannot_own_tenant() RETURNS trigger CREATE FUNCTION public.guard_no_delete_core_plans() RETURNS trigger LANGUAGE plpgsql AS $$ begin if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro') then raise exception 'Plano padrão (%) não pode ser removido.', old.key using errcode = 'P0001'; end if; return old; end $$; ALTER FUNCTION public.guard_no_delete_core_plans() OWNER TO supabase_admin; -- -- Name: guard_patient_cannot_own_tenant(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.guard_patient_cannot_own_tenant() RETURNS trigger CREATE FUNCTION public.reactivate_subscription(p_subscription_id uuid) RETURNS public.subscriptions LANGUAGE plpgsql SECURITY DEFINER AS $$ declare v_sub public.subscriptions; v_owner_type text; v_owner_ref uuid; begin select * into v_sub from public.subscriptions where id = p_subscription_id for update; if not found then raise exception 'Subscription não encontrada'; end if; if v_sub.status = 'active' then return v_sub; end if; if v_sub.tenant_id is not null then v_owner_type := 'clinic'; v_owner_ref := v_sub.tenant_id; elsif v_sub.user_id is not null then v_owner_type := 'therapist'; v_owner_ref := v_sub.user_id; else v_owner_type := null; v_owner_ref := null; end if; update public.subscriptions set status = 'active', cancel_at_period_end = false, updated_at = now() where id = p_subscription_id returning * into v_sub; insert into public.subscription_events( subscription_id, owner_id, owner_type, owner_ref, event_type, old_plan_id, new_plan_id, created_by, reason, source, metadata ) values ( v_sub.id, v_owner_ref, v_owner_type, v_owner_ref, 'reactivated', v_sub.plan_id, v_sub.plan_id, auth.uid(), 'Reativação manual via admin', 'admin_panel', jsonb_build_object('previous_status', 'canceled') ); if v_owner_ref is not null then insert into public.entitlements_invalidation(owner_id, changed_at) values (v_owner_ref, now()) on conflict (owner_id) do update set changed_at = excluded.changed_at; end if; return v_sub; end; $$; ALTER FUNCTION public.reactivate_subscription(p_subscription_id uuid) OWNER TO supabase_admin; -- -- Name: rebuild_owner_entitlements(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.rebuild_owner_entitlements(p_owner_id uuid) RETURNS void CREATE FUNCTION public.rebuild_owner_entitlements(p_owner_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ declare v_plan_id uuid; begin -- Plano ativo do owner (owner = subscriptions.user_id) select s.plan_id into v_plan_id from public.subscriptions s where s.user_id = p_owner_id and s.status = 'active' order by s.created_at desc limit 1; -- Sempre zera entitlements do owner (rebuild) delete from public.owner_feature_entitlements e where e.owner_id = p_owner_id; -- Se não tem assinatura ativa, acabou if v_plan_id is null then return; end if; -- Recria entitlements esperados pelo plano insert into public.owner_feature_entitlements (owner_id, feature_key, sources, limits_list) select p_owner_id as owner_id, f.key as feature_key, array['plan'::text] as sources, '{}'::jsonb as limits_list from public.plan_features pf join public.features f on f.id = pf.feature_id where pf.plan_id = v_plan_id; end; $$; ALTER FUNCTION public.rebuild_owner_entitlements(p_owner_id uuid) OWNER TO supabase_admin; -- -- Name: revoke_support_session(text); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.revoke_support_session(p_token text) RETURNS boolean CREATE FUNCTION public.set_tenant_feature_exception(p_tenant_id uuid, p_feature_key text, p_enabled boolean, p_reason text DEFAULT NULL::text) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ begin -- ✅ Só owner ou admin do tenant podem alterar features if not exists ( select 1 from public.tenant_members where tenant_id = p_tenant_id and user_id = auth.uid() and role in ('owner', 'admin') and status = 'active' ) then raise exception 'Acesso negado: apenas owner/admin pode alterar features do tenant.'; end if; insert into public.tenant_features (tenant_id, feature_key, enabled) values (p_tenant_id, p_feature_key, p_enabled) on conflict (tenant_id, feature_key) do update set enabled = excluded.enabled; insert into public.tenant_feature_exceptions_log ( tenant_id, feature_key, enabled, reason, created_by ) values ( p_tenant_id, p_feature_key, p_enabled, p_reason, auth.uid() ); end; $$; ALTER FUNCTION public.set_tenant_feature_exception(p_tenant_id uuid, p_feature_key text, p_enabled boolean, p_reason text) OWNER TO supabase_admin; -- -- Name: set_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.set_updated_at() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$; ALTER FUNCTION public.set_updated_at() OWNER TO supabase_admin; -- -- Name: set_updated_at_recurrence(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger CREATE FUNCTION public.subscription_intents_view_insert() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$ declare v_target text; v_plan_id uuid; begin select p.id, p.target into v_plan_id, v_target from public.plans p where p.key = new.plan_key; if v_plan_id is null then raise exception 'Plano inválido: plan_key=%', new.plan_key; end if; if lower(v_target) = 'clinic' then if new.tenant_id is null then raise exception 'Intenção clinic exige tenant_id.'; end if; insert into public.subscription_intents_tenant ( id, tenant_id, created_by_user_id, email, plan_id, plan_key, interval, amount_cents, currency, status, source, notes, created_at, paid_at ) values ( coalesce(new.id, gen_random_uuid()), new.tenant_id, new.created_by_user_id, new.email, v_plan_id, new.plan_key, coalesce(new.interval,'month'), new.amount_cents, coalesce(new.currency,'BRL'), coalesce(new.status,'pending'), coalesce(new.source,'manual'), new.notes, coalesce(new.created_at, now()), new.paid_at ); new.plan_target := 'clinic'; return new; end if; -- therapist ou supervisor → tabela personal if lower(v_target) in ('therapist', 'supervisor') then insert into public.subscription_intents_personal ( id, user_id, created_by_user_id, email, plan_id, plan_key, interval, amount_cents, currency, status, source, notes, created_at, paid_at ) values ( coalesce(new.id, gen_random_uuid()), new.user_id, new.created_by_user_id, new.email, v_plan_id, new.plan_key, coalesce(new.interval,'month'), new.amount_cents, coalesce(new.currency,'BRL'), coalesce(new.status,'pending'), coalesce(new.source,'manual'), new.notes, coalesce(new.created_at, now()), new.paid_at ); new.plan_target := lower(v_target); -- 'therapist' ou 'supervisor' return new; end if; raise exception 'Target de plano não suportado: %', v_target; end; $$; ALTER FUNCTION public.subscription_intents_view_insert() OWNER TO supabase_admin; -- -- Name: subscriptions_validate_scope(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.subscriptions_validate_scope() RETURNS trigger CREATE FUNCTION public.subscriptions_validate_scope() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE v_target text; BEGIN SELECT lower(p.target) INTO v_target FROM public.plans p WHERE p.id = NEW.plan_id; IF v_target IS NULL THEN RAISE EXCEPTION 'Plano inválido (target nulo).'; END IF; IF v_target = 'clinic' THEN IF NEW.tenant_id IS NULL THEN RAISE EXCEPTION 'Assinatura clinic exige tenant_id.'; END IF; IF NEW.user_id IS NOT NULL THEN RAISE EXCEPTION 'Assinatura clinic não pode ter user_id (XOR).'; END IF; ELSIF v_target IN ('therapist', 'supervisor') THEN -- supervisor é pessoal como therapist IF NEW.tenant_id IS NOT NULL THEN RAISE EXCEPTION 'Assinatura % não deve ter tenant_id.', v_target; END IF; IF NEW.user_id IS NULL THEN RAISE EXCEPTION 'Assinatura % exige user_id.', v_target; END IF; ELSIF v_target = 'patient' THEN IF NEW.tenant_id IS NOT NULL THEN RAISE EXCEPTION 'Assinatura patient não deve ter tenant_id.'; END IF; IF NEW.user_id IS NULL THEN RAISE EXCEPTION 'Assinatura patient exige user_id.'; END IF; ELSE RAISE EXCEPTION 'Target de plano inválido: %', v_target; END IF; RETURN NEW; END; $$; ALTER FUNCTION public.subscriptions_validate_scope() OWNER TO supabase_admin; -- -- Name: sync_busy_mirror_agenda_eventos(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.sync_busy_mirror_agenda_eventos() RETURNS trigger CREATE FUNCTION public.toggle_plan(owner uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$ declare current_key text; new_key text; begin select p.key into current_key from subscriptions s join plans p on p.id = s.plan_id where s.owner_id = owner and s.status = 'active'; new_key := case when current_key = 'pro' then 'free' else 'pro' end; update subscriptions s set plan_id = p.id from plans p where p.key = new_key and s.owner_id = owner and s.status = 'active'; end; $$; ALTER FUNCTION public.toggle_plan(owner uuid) OWNER TO supabase_admin; -- -- Name: transition_subscription(uuid, text, text, jsonb); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.transition_subscription(p_subscription_id uuid, p_to_status text, p_reason text DEFAULT NULL::text, p_metadata jsonb DEFAULT NULL::jsonb) RETURNS public.subscriptions CREATE FUNCTION public.transition_subscription(p_subscription_id uuid, p_to_status text, p_reason text DEFAULT NULL::text, p_metadata jsonb DEFAULT NULL::jsonb) RETURNS public.subscriptions LANGUAGE plpgsql SECURITY DEFINER AS $$ declare v_sub public.subscriptions; v_uid uuid; v_is_allowed boolean := false; begin v_uid := auth.uid(); select * into v_sub from public.subscriptions where id = p_subscription_id; if not found then raise exception 'Assinatura não encontrada'; end if; -- ===================================================== -- 🔐 BLOCO DE AUTORIZAÇÃO -- ===================================================== -- 1) SaaS admin pode tudo if is_saas_admin() then v_is_allowed := true; end if; -- 2) Assinatura pessoal (therapist) if not v_is_allowed and v_sub.tenant_id is null and v_sub.user_id = v_uid then v_is_allowed := true; end if; -- 3) Assinatura de clinic (tenant) if not v_is_allowed and v_sub.tenant_id is not null then if exists ( select 1 from public.tenant_members tm where tm.tenant_id = v_sub.tenant_id and tm.user_id = v_uid and tm.status = 'active' and tm.role = 'tenant_admin' ) then v_is_allowed := true; end if; end if; if not v_is_allowed then raise exception 'Sem permissão para transicionar esta assinatura'; end if; -- ===================================================== -- 🧠 TRANSIÇÃO -- ===================================================== update public.subscriptions set status = p_to_status, updated_at = now(), cancelled_at = case when p_to_status = 'cancelled' then now() else cancelled_at end, suspended_at = case when p_to_status = 'suspended' then now() else suspended_at end, past_due_since = case when p_to_status = 'past_due' then now() else past_due_since end, expired_at = case when p_to_status = 'expired' then now() else expired_at end, activated_at = case when p_to_status = 'active' then now() else activated_at end where id = p_subscription_id returning * into v_sub; -- ===================================================== -- 🧾 EVENT LOG -- ===================================================== insert into public.subscription_events ( subscription_id, owner_id, event_type, created_at, created_by, source, reason, metadata, owner_type, owner_ref ) values ( v_sub.id, coalesce(v_sub.tenant_id, v_sub.user_id), 'status_changed', now(), v_uid, 'manual_transition', p_reason, p_metadata, case when v_sub.tenant_id is not null then 'tenant' else 'personal' end, coalesce(v_sub.tenant_id, v_sub.user_id) ); return v_sub; end; $$; ALTER FUNCTION public.transition_subscription(p_subscription_id uuid, p_to_status text, p_reason text, p_metadata jsonb) OWNER TO supabase_admin; -- -- Name: trg_fn_financial_records_auto_overdue(); Type: FUNCTION; Schema: public; Owner: supabase_admin -- CREATE FUNCTION public.trg_fn_financial_records_auto_overdue() RETURNS trigger