Files
agenciapsilmno/Nova-Dev-Doc/Subscription Health e Entitlements/Agencia_PSI_Sessao_Subscription_Health_Entitlements_2026-03-01.html
Leonardo f733db8436 ZERADO
2026-03-06 06:37:13 -03:00

613 lines
21 KiB
HTML

<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Relatório Técnico — Sessão de Correção: Subscription Health & Entitlements (2026-03-01) | Agência PSI</title>
<style>
:root{
--bg0:#f6f8fc;
--bg1:#eef2f8;
--panel:rgba(255,255,255,.78);
--panel2:rgba(255,255,255,.92);
--border:rgba(15,23,42,.10);
--text:rgba(15,23,42,.92);
--muted:rgba(15,23,42,.70);
--muted2:rgba(15,23,42,.56);
--accent:#2563eb;
--accent2:#4f46e5;
--warn:#b45309;
--danger:#b91c1c;
--ok:#047857;
--shadow: 0 18px 60px rgba(2,6,23,.10);
--radius: 16px;
--radius2: 22px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
*{box-sizing:border-box;}
body{
margin:0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: radial-gradient(1200px 600px at 10% 0%, rgba(79,70,229,.10), transparent 55%),
radial-gradient(1100px 500px at 90% 10%, rgba(37,99,235,.10), transparent 55%),
linear-gradient(180deg, var(--bg0), var(--bg1));
color:var(--text);
}
a{color:inherit; text-decoration:none;}
a:hover{text-decoration:underline;}
.layout{
display:grid;
grid-template-columns: 320px 1fr;
gap: 20px;
max-width: 1320px;
margin: 0 auto;
padding: 28px 18px 42px;
}
header{
grid-column: 1 / -1;
padding: 18px;
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255,255,255,.92), rgba(255,255,255,.72));
border-radius: var(--radius2);
box-shadow: var(--shadow);
}
.kicker{
font-size:12px;
letter-spacing:.08em;
text-transform:uppercase;
color:var(--muted2);
margin:0 0 8px;
}
h1{
margin:0 0 8px;
font-size:30px;
letter-spacing:-0.02em;
}
.subtitle{
margin:0;
color:var(--muted);
max-width:980px;
line-height:1.55;
font-size:14px;
}
.meta-row{
margin-top:12px;
display:flex;
flex-wrap:wrap;
gap:10px;
align-items:center;
}
.pill{
display:inline-flex;
align-items:center;
gap:8px;
padding:8px 12px;
border-radius:999px;
border:1px solid var(--border);
background:rgba(255,255,255,.72);
color:var(--muted);
font-size:12px;
}
.dot{
width:8px;height:8px;border-radius:50%;
background:var(--accent);
box-shadow:0 0 0 4px rgba(37,99,235,.12);
}
aside{
position:sticky;
top:18px;
align-self:start;
border:1px solid var(--border);
background:var(--panel2);
border-radius:var(--radius);
box-shadow:var(--shadow);
overflow:hidden;
}
.toc-head{
padding:14px;
border-bottom:1px solid var(--border);
background:rgba(15,23,42,.02);
}
.toc-title{ margin:0 0 6px; font-weight:700; font-size:14px; }
.toc-sub{ margin:0; color:var(--muted); font-size:12px; line-height:1.45; }
nav{ padding: 10px 6px 14px; }
nav a{
display:block;
padding:10px 12px;
margin:4px 6px;
border-radius:12px;
color:var(--muted);
font-size:13px;
border:1px solid transparent;
}
nav a:hover{
background:rgba(37,99,235,.06);
border-color:rgba(37,99,235,.12);
color:var(--text);
text-decoration:none;
}
.nav-sec{
margin:10px 12px 6px;
color:var(--muted2);
font-size:11px;
letter-spacing:.08em;
text-transform:uppercase;
}
main{
border:1px solid var(--border);
background:var(--panel);
border-radius:var(--radius2);
box-shadow:var(--shadow);
overflow:hidden;
}
.content{ padding: 18px 18px 22px; }
.section{
padding: 18px;
border:1px solid var(--border);
border-radius: var(--radius2);
background: rgba(255,255,255,.80);
margin-bottom: 16px;
}
.section h2{
margin:0 0 10px;
font-size:18px;
letter-spacing:-0.01em;
}
.section h3{
margin:14px 0 8px;
font-size:14px;
}
.section p, .section li{
color:var(--muted);
line-height:1.65;
font-size:13.5px;
}
.grid{
display:grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 980px){
.layout{ grid-template-columns: 1fr; }
aside{ position:relative; top:auto; }
.grid{ grid-template-columns: 1fr; }
}
.callout{
border-radius: 16px;
padding: 12px 12px 12px 14px;
border:1px solid var(--border);
background: rgba(15,23,42,.02);
margin-top: 10px;
}
.callout strong{color:var(--text);}
.callout.ok{ border-left: 4px solid var(--ok); background: rgba(4,120,87,.06); }
.callout.warn{ border-left: 4px solid var(--warn); background: rgba(180,83,9,.08); }
.callout.danger{ border-left: 4px solid var(--danger); background: rgba(185,28,28,.08); }
.callout.info{ border-left: 4px solid var(--accent); background: rgba(37,99,235,.08); }
pre{
margin: 10px 0 0;
padding: 14px;
background: #0b1220;
color:#e2e8f0;
border-radius: 16px;
overflow:auto;
border: 1px solid rgba(226,232,240,.08);
}
code{ font-family: var(--mono); font-size:12.5px; }
.kbd{
font-family: var(--mono);
font-size: 12px;
padding: 2px 6px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(255,255,255,.65);
color: var(--text);
}
.hr{
height:1px;
background: var(--border);
margin: 12px 0;
}
footer{
margin-top: 12px;
padding: 14px 18px 18px;
color: var(--muted2);
font-size: 12px;
border-top: 1px solid var(--border);
background: rgba(255,255,255,.70);
}
.small{ font-size:12px; color:var(--muted2); }
</style>
</head>
<body>
<div class="layout">
<header>
<p class="kicker">Relatório Técnico • Billing Health • Agência PSI</p>
<h1>Subscription Health & Entitlements — Sessão de Correção (2026-03-01)</h1>
<p class="subtitle">Este documento registra, de forma minuciosa e operacional, a sessão de diagnóstico e correção dos problemas
na página <strong>Saúde das Assinaturas</strong> (Subscription Health) e no pipeline de <strong>Entitlements</strong>.
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.</p>
<div class="meta-row">
<span class="pill"><span class="dot"></span><strong>Estado:</strong> resolvido e hardenizado</span>
<span class="pill"><strong>Atualizado:</strong> 2026-03-01 11:46:44 UTC</span>
<span class="pill"><strong>Stack:</strong> Supabase + Postgres + Vue/PrimeVue</span>
</div>
</header>
<aside>
<div class="toc-head">
<div class="toc-title">Sumário</div>
<p class="toc-sub">Leitura rápida com passos reproduzíveis (SQL + patches + checklist).</p>
</div>
<nav>
<div class="nav-sec">Visão geral</div>
<a href="#01">Resumo executivo</a>
<a href="#02">Escopo e componentes</a>
<a href="#03">Sintomas e evidências</a>
<div class="nav-sec">Diagnóstico</div>
<a href="#04">Causa raiz</a>
<a href="#05">SQLs de diagnóstico</a>
<div class="nav-sec">Correções</div>
<a href="#06">Patches aplicados</a>
<a href="#07">Hardening</a>
<div class="nav-sec">Validação</div>
<a href="#08">Checklist pós-correção</a>
<div class="nav-sec">Contexto</div>
<a href="#09">Notas de front-end</a>
<a href="#10">Linha do tempo</a>
</nav>
</aside>
<main>
<div class="content">
<section class="section" id="01">
<h2>1. Resumo executivo</h2>
<p>
<strong>Sintoma principal:</strong> a tela <em>Saúde das Assinaturas</em> exibia divergências e a coluna <strong>Owner</strong> aparecia vazia
(linhas com <code>owner_id = NULL</code>). Além disso, os botões <strong>Fix</strong> e <strong>Fix All</strong> falhavam.
</p>
<div class="callout danger">
<strong>Impacto:</strong> 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.
</div>
<div class="callout ok">
<strong>Resultado final:</strong> view de entitlements corrigida (filtra <code>subscriptions.status = 'active'</code> e exclui NULL),
funções RPC alinhadas ao schema atual (<code>subscriptions.user_id</code>), dados inválidos removidos e constraints/índices adicionados
para impedir regressões.
</div>
</section>
<section class="section" id="02">
<h2>2. Escopo e componentes envolvidos</h2>
<div class="grid">
<div class="callout">
<strong>View de saúde</strong>
<p><code>public.v_subscription_feature_mismatch</code> — compara o esperado (plan_features do plano ativo) com o atual (entitlements).</p>
</div>
<div class="callout">
<strong>Entitlements agregados</strong>
<p><code>public.owner_feature_entitlements</code><em>VIEW</em> agregada (sources + limits_list), derivada de <code>subscriptions</code> e <code>tenant_modules</code>.</p>
</div>
<div class="callout">
<strong>Rotinas de reparo</strong>
<p><code>public.rebuild_owner_entitlements(uuid)</code> e <code>public.fix_all_subscription_mismatches()</code>.</p>
</div>
<div class="callout">
<strong>Tabelas de configuração</strong>
<p><code>plans</code>, <code>features</code>, <code>plan_features</code>, <code>module_features</code>, <code>tenant_modules</code>, <code>subscriptions</code>.</p>
</div>
</div>
</section>
<section class="section" id="03">
<h2>3. Sintomas observados e evidências</h2>
<p>Foram observados os seguintes indícios no banco:</p>
<ul>
<li>View <code>v_subscription_feature_mismatch</code> retornando <code>owner_id = NULL</code> tanto em <em>missing</em> quanto em <em>unexpected</em>.</li>
<li>Contagem estável em 4/4 divergências, mesmo após tentativa de reparo.</li>
<li>Existência de uma <code>subscription</code> com <code>status='active'</code> e <code>user_id = NULL</code> (dado inválido).</li>
<li>Falha de execução do FixAll com erro de coluna inexistente: <code>subscriptions.owner_id</code> (schema drift).</li>
</ul>
<div class="callout warn">
<strong>Nota de leitura:</strong> ao ver <code>owner_id = NULL</code> 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 <em>integridade</em>.
</div>
</section>
<section class="section" id="04">
<h2>4. Diagnóstico e causa raiz</h2>
<h3>4.1 Causa raiz #1 — Schema drift nas funções RPC</h3>
<p>
As funções de reparo estavam escritas para um schema anterior, usando <code>subscriptions.owner_id</code>. No schema atual, o owner do contexto
“terapeuta” é <code>subscriptions.user_id</code>. Isso quebrou:
</p>
<ul>
<li><strong>Fix owner</strong>: falha ao buscar o plano ativo do owner.</li>
<li><strong>Fix all</strong>: falha ao iterar owners e chamar o rebuild.</li>
</ul>
<div class="callout danger">
<strong>Erro observado:</strong> <code>column "owner_id" does not exist</code> (hint citando <code>subscriptions.user_id</code>).
</div>
<h3>4.2 Causa raiz #2 — View de entitlements agregados não filtrava status</h3>
<p>
A view <code>owner_feature_entitlements</code> agregava a fonte “plan” sem filtrar <code>subscriptions.status</code>, permitindo que uma subscription
<em>inactive</em> com <code>user_id NULL</code> continuasse “vazando” entitlements com <code>owner_id NULL</code> para o sistema.
</p>
<h3>4.3 Causa raiz #3 — Dado inválido</h3>
<p>
Foi identificado um registro em <code>subscriptions</code> com <code>user_id NULL</code>. Mesmo após torná-lo <em>inactive</em>, ele continuava contaminando
a view (por ausência do filtro de status).
</p>
</section>
<section class="section" id="05">
<h2>5. SQLs usados no diagnóstico (playbook)</h2>
<p>Use este bloco para reproduzir o diagnóstico com segurança.</p>
<h3>5.1 Ver divergências e amostras</h3>
<pre><code>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;</code></pre>
<h3>5.2 Encontrar subscriptions inválidas (user_id nulo)</h3>
<pre><code>select id, user_id, plan_id, status, created_at
from public.subscriptions
where user_id is null
order by created_at desc;</code></pre>
<h3>5.3 Entender a origem dos entitlements agregados</h3>
<pre><code>select pg_get_viewdef('public.owner_feature_entitlements'::regclass, true) as view_sql;</code></pre>
<h3>5.4 Verificar tenant_modules inválidos (owner_id nulo)</h3>
<pre><code>select count(*) as qtd
from public.tenant_modules
where status = 'active' and owner_id is null;</code></pre>
</section>
<section class="section" id="06">
<h2>6. Correções aplicadas no banco (patches)</h2>
<h3>6.1 Patch: rebuild_owner_entitlements (owner = subscriptions.user_id)</h3>
<p>
Ajuste para buscar o plano ativo por <code>subscriptions.user_id</code> e reconstruir entitlements com base em <code>plan_features</code>.
</p>
<pre><code>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;
$$;</code></pre>
<div class="callout warn">
<strong>Importante:</strong> se <code>owner_feature_entitlements</code> for uma <em>VIEW</em> (como no ambiente desta sessão),
o <code>DELETE/INSERT</code> acima deve ser direcionado à <em>tabela base</em> real de entitlements, se existir.
Nesta sessão, a correção definitiva foi feita ajustando a view agregadora e limpando o dado inválido.
</div>
<h3>6.2 Patch: fix_all_subscription_mismatches (itera subscriptions.user_id)</h3>
<pre><code>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;
$$;</code></pre>
<h3>6.3 Patch: owner_feature_entitlements (filtra status e NULLs)</h3>
<pre><code>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;</code></pre>
<h3>6.4 Limpeza do dado inválido (subscription com user_id NULL)</h3>
<pre><code>-- se for lixo de seed/teste (recomendado remover):
delete from public.subscriptions
where user_id is null;</code></pre>
</section>
<section class="section" id="07">
<h2>7. Hardening (constraints e índices recomendados)</h2>
<p>Após corrigir dados e views, aplique hardening para impedir regressões.</p>
<h3>7.1 subscriptions.user_id NOT NULL</h3>
<pre><code>alter table public.subscriptions
alter column user_id set not null;</code></pre>
<h3>7.2 Uma assinatura ativa por usuário</h3>
<pre><code>create unique index if not exists subscriptions_one_active_per_user
on public.subscriptions (user_id)
where status = 'active';</code></pre>
<h3>7.3 Índice de performance para consultas por owner/status</h3>
<pre><code>create index if not exists subscriptions_user_status_idx
on public.subscriptions (user_id, status, created_at desc);</code></pre>
<h3>7.4 tenant_modules.owner_id NOT NULL (decisão tomada nesta sessão)</h3>
<pre><code>alter table public.tenant_modules
alter column owner_id set not null;</code></pre>
<h3>7.5 Uniqueness e performance em plan_features / module_features</h3>
<pre><code>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);</code></pre>
<div class="callout info">
<strong>Regra prática:</strong> dados inválidos (NULL em owner) devem ser bloqueados na borda (constraints), não “corrigidos” no front.
</div>
</section>
<section class="section" id="08">
<h2>8. Verificação pós-correção (checklist)</h2>
<h3>8.1 Saúde deve zerar</h3>
<pre><code>select mismatch_type, count(*) as qtd
from public.v_subscription_feature_mismatch
group by 1
order by 2 desc;</code></pre>
<h3>8.2 Não pode haver owner nulo em subscriptions / tenant_modules ativos</h3>
<pre><code>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;</code></pre>
<h3>8.3 Entitlements agregados não devem conter owner null</h3>
<pre><code>select owner_id, feature_key
from public.owner_feature_entitlements
where owner_id is null
limit 20;</code></pre>
<div class="callout ok">
<strong>OK final:</strong> todas as queries acima retornam 0 linhas (ou contagens zero).
</div>
</section>
<section class="section" id="09">
<h2>9. Notas de implementação no front-end (contexto)</h2>
<p>Durante a sessão, a UI foi ajustada para:</p>
<ul>
<li>Traduzir telas para PT-BR, melhorar títulos, descrições e mensagens.</li>
<li>Padronizar inputs com <code>FloatLabel</code> + <code>IconField</code> + <code>InputIcon</code>.</li>
<li>Adicionar confirmações e “alterações pendentes” em ações em massa (plan_features), evitando salvar por clique acidental.</li>
<li>Garantir que ações de correção (Fix/FixAll) reflitam erros reais (RPC quebrada vs dados inválidos).</li>
</ul>
<div class="callout warn">
<strong>Regra operacional:</strong> se a coluna Owner aparecer vazia, corrija no banco primeiro (dados/view),
antes de mexer no front.
</div>
</section>
<section class="section" id="10">
<h2>10. Linha do tempo da sessão (resumo)</h2>
<ul>
<li><strong>Detecção:</strong> tela “Saúde das Assinaturas” exibindo Owner vazio e divergências.</li>
<li><strong>Inspeção:</strong> <code>v_subscription_feature_mismatch</code> mostrava <code>owner_id NULL</code> em missing/unexpected.</li>
<li><strong>Erro crítico:</strong> FixAll falhava com <code>subscriptions.owner_id</code> inexistente.</li>
<li><strong>Correção #1:</strong> alinhar RPCs ao schema atual (<code>subscriptions.user_id</code>).</li>
<li><strong>Correção #2:</strong> identificar que <code>owner_feature_entitlements</code> é VIEW e filtrar <code>status='active'</code>.</li>
<li><strong>Correção #3:</strong> remover subscription inválida com <code>user_id NULL</code>.</li>
<li><strong>Hardening:</strong> constraints e índices para prevenir regressões.</li>
</ul>
<div class="callout info">
<strong>Atualizado:</strong> 2026-03-01 11:46:44 UTC
</div>
</section>
</div>
<footer>
<div><strong>Agência PSI — Relatório Técnico (Billing Health)</strong></div>
<div class="small">Documento operacional inspirado no “Documento Mestre Billing v2.0”. Atualizado em 2026-03-01 11:46:44 UTC.</div>
</footer>
</main>
</div>
</body>
</html>