Relatório Técnico • Billing Health • Agência PSI

Subscription Health & Entitlements — Sessão de Correção (2026-03-01)

Este documento registra, de forma minuciosa e operacional, a sessão de diagnóstico e correção dos problemas na página Saúde das Assinaturas (Subscription Health) e no pipeline de Entitlements. O objetivo é permitir que qualquer programador entenda o incidente, replique o diagnóstico e aplique correções com segurança, mesmo sem ter acompanhado a conversa original.

Estado: resolvido e hardenizado Atualizado: 2026-03-01 11:46:44 UTC Stack: Supabase + Postgres + Vue/PrimeVue

1. Resumo executivo

Sintoma principal: a tela Saúde das Assinaturas exibia divergências e a coluna Owner aparecia vazia (linhas com owner_id = NULL). Além disso, os botões Fix e Fix All falhavam.

Impacto: a ferramenta de diagnóstico do Billing ficou pouco confiável e os reparos automáticos não executavam, impedindo correções rápidas após mudanças de plano/feature.
Resultado final: view de entitlements corrigida (filtra subscriptions.status = 'active' e exclui NULL), funções RPC alinhadas ao schema atual (subscriptions.user_id), dados inválidos removidos e constraints/índices adicionados para impedir regressões.

2. Escopo e componentes envolvidos

View de saúde

public.v_subscription_feature_mismatch — compara o esperado (plan_features do plano ativo) com o atual (entitlements).

Entitlements agregados

public.owner_feature_entitlementsVIEW agregada (sources + limits_list), derivada de subscriptions e tenant_modules.

Rotinas de reparo

public.rebuild_owner_entitlements(uuid) e public.fix_all_subscription_mismatches().

Tabelas de configuração

plans, features, plan_features, module_features, tenant_modules, subscriptions.

3. Sintomas observados e evidências

Foram observados os seguintes indícios no banco:

  • View v_subscription_feature_mismatch retornando owner_id = NULL tanto em missing quanto em unexpected.
  • Contagem estável em 4/4 divergências, mesmo após tentativa de reparo.
  • Existência de uma subscription com status='active' e user_id = NULL (dado inválido).
  • Falha de execução do FixAll com erro de coluna inexistente: subscriptions.owner_id (schema drift).
Nota de leitura: ao ver owner_id = NULL em divergências, trate como anomalia de dados ou view/joins permissivos. Na prática, “owner nulo” não é um caso de negócio — é um caso de integridade.

4. Diagnóstico e causa raiz

4.1 Causa raiz #1 — Schema drift nas funções RPC

As funções de reparo estavam escritas para um schema anterior, usando subscriptions.owner_id. No schema atual, o owner do contexto “terapeuta” é subscriptions.user_id. Isso quebrou:

  • Fix owner: falha ao buscar o plano ativo do owner.
  • Fix all: falha ao iterar owners e chamar o rebuild.
Erro observado: column "owner_id" does not exist (hint citando subscriptions.user_id).

4.2 Causa raiz #2 — View de entitlements agregados não filtrava status

A view owner_feature_entitlements agregava a fonte “plan” sem filtrar subscriptions.status, permitindo que uma subscription inactive com user_id NULL continuasse “vazando” entitlements com owner_id NULL para o sistema.

4.3 Causa raiz #3 — Dado inválido

Foi identificado um registro em subscriptions com user_id NULL. Mesmo após torná-lo inactive, ele continuava contaminando a view (por ausência do filtro de status).

5. SQLs usados no diagnóstico (playbook)

Use este bloco para reproduzir o diagnóstico com segurança.

5.1 Ver divergências e amostras

select mismatch_type, count(*) as qtd
from public.v_subscription_feature_mismatch
group by 1
order by 2 desc;

select owner_id, feature_key, mismatch_type
from public.v_subscription_feature_mismatch
order by owner_id nulls first, feature_key
limit 50;

5.2 Encontrar subscriptions inválidas (user_id nulo)

select id, user_id, plan_id, status, created_at
from public.subscriptions
where user_id is null
order by created_at desc;

5.3 Entender a origem dos entitlements agregados

select pg_get_viewdef('public.owner_feature_entitlements'::regclass, true) as view_sql;

5.4 Verificar tenant_modules inválidos (owner_id nulo)

select count(*) as qtd
from public.tenant_modules
where status = 'active' and owner_id is null;

6. Correções aplicadas no banco (patches)

6.1 Patch: rebuild_owner_entitlements (owner = subscriptions.user_id)

Ajuste para buscar o plano ativo por subscriptions.user_id e reconstruir entitlements com base em plan_features.

create or replace function public.rebuild_owner_entitlements(p_owner_id uuid)
returns void
language plpgsql
security definer
as $$
declare
  v_plan_id uuid;
begin
  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;

  delete from public.owner_feature_entitlements e
  where e.owner_id = p_owner_id;

  if v_plan_id is null then
    return;
  end if;

  insert into public.owner_feature_entitlements (owner_id, feature_key, sources, limits_list)
  select
    p_owner_id,
    f.key,
    array['plan'::text],
    '{}'::jsonb
  from public.plan_features pf
  join public.features f on f.id = pf.feature_id
  where pf.plan_id = v_plan_id;
end;
$$;
Importante: se owner_feature_entitlements for uma VIEW (como no ambiente desta sessão), o DELETE/INSERT acima deve ser direcionado à tabela base real de entitlements, se existir. Nesta sessão, a correção definitiva foi feita ajustando a view agregadora e limpando o dado inválido.

6.2 Patch: fix_all_subscription_mismatches (itera subscriptions.user_id)

create or replace 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;
$$;

6.3 Patch: owner_feature_entitlements (filtra status e NULLs)

create or replace view public.owner_feature_entitlements as
with base as (
  select
    s.user_id as owner_id,
    f.key as feature_key,
    pf.limits,
    'plan'::text as source
  from public.subscriptions s
  join public.plan_features pf
    on pf.plan_id = s.plan_id
   and pf.enabled = true
  join public.features f
    on f.id = pf.feature_id
  where s.status = 'active'
    and s.user_id is not null

  union all

  select
    tm.owner_id,
    f.key as feature_key,
    mf.limits,
    'module'::text as source
  from public.tenant_modules tm
  join public.modules m
    on m.id = tm.module_id
   and m.is_active = true
  join public.module_features mf
    on mf.module_id = m.id
   and mf.enabled = true
  join public.features f
    on f.id = mf.feature_id
  where tm.status = 'active'
    and tm.owner_id is not null
)
select
  owner_id,
  feature_key,
  array_agg(distinct source) as sources,
  jsonb_agg(limits) filter (where limits is not null) as limits_list
from base
group by owner_id, feature_key;

6.4 Limpeza do dado inválido (subscription com user_id NULL)

-- se for lixo de seed/teste (recomendado remover):
delete from public.subscriptions
where user_id is null;

7. Hardening (constraints e índices recomendados)

Após corrigir dados e views, aplique hardening para impedir regressões.

7.1 subscriptions.user_id NOT NULL

alter table public.subscriptions
alter column user_id set not null;

7.2 Uma assinatura ativa por usuário

create unique index if not exists subscriptions_one_active_per_user
on public.subscriptions (user_id)
where status = 'active';

7.3 Índice de performance para consultas por owner/status

create index if not exists subscriptions_user_status_idx
on public.subscriptions (user_id, status, created_at desc);

7.4 tenant_modules.owner_id NOT NULL (decisão tomada nesta sessão)

alter table public.tenant_modules
alter column owner_id set not null;

7.5 Uniqueness e performance em plan_features / module_features

create unique index if not exists plan_features_plan_feature_ux
on public.plan_features (plan_id, feature_id);

create index if not exists plan_features_plan_enabled_idx
on public.plan_features (plan_id, enabled);

create unique index if not exists module_features_module_feature_ux
on public.module_features (module_id, feature_id);
Regra prática: dados inválidos (NULL em owner) devem ser bloqueados na borda (constraints), não “corrigidos” no front.

8. Verificação pós-correção (checklist)

8.1 Saúde deve zerar

select mismatch_type, count(*) as qtd
from public.v_subscription_feature_mismatch
group by 1
order by 2 desc;

8.2 Não pode haver owner nulo em subscriptions / tenant_modules ativos

select count(*) as subs_user_null
from public.subscriptions
where user_id is null;

select count(*) as tenant_modules_active_owner_null
from public.tenant_modules
where status='active' and owner_id is null;

8.3 Entitlements agregados não devem conter owner null

select owner_id, feature_key
from public.owner_feature_entitlements
where owner_id is null
limit 20;
OK final: todas as queries acima retornam 0 linhas (ou contagens zero).

9. Notas de implementação no front-end (contexto)

Durante a sessão, a UI foi ajustada para:

  • Traduzir telas para PT-BR, melhorar títulos, descrições e mensagens.
  • Padronizar inputs com FloatLabel + IconField + InputIcon.
  • Adicionar confirmações e “alterações pendentes” em ações em massa (plan_features), evitando salvar por clique acidental.
  • Garantir que ações de correção (Fix/FixAll) reflitam erros reais (RPC quebrada vs dados inválidos).
Regra operacional: se a coluna Owner aparecer vazia, corrija no banco primeiro (dados/view), antes de mexer no front.

10. Linha do tempo da sessão (resumo)

  • Detecção: tela “Saúde das Assinaturas” exibindo Owner vazio e divergências.
  • Inspeção: v_subscription_feature_mismatch mostrava owner_id NULL em missing/unexpected.
  • Erro crítico: FixAll falhava com subscriptions.owner_id inexistente.
  • Correção #1: alinhar RPCs ao schema atual (subscriptions.user_id).
  • Correção #2: identificar que owner_feature_entitlements é VIEW e filtrar status='active'.
  • Correção #3: remover subscription inválida com user_id NULL.
  • Hardening: constraints e índices para prevenir regressões.
Atualizado: 2026-03-01 11:46:44 UTC