This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions

View File

@@ -0,0 +1,672 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Documento Mestre — Billing (Plans, Pricing, Subscriptions, Entitlements) v2.0 | 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">Documento Mestre • Billing • Agência PSI</p>
<h1>Plans, Pricing, Subscriptions & Entitlements — v2.0</h1>
<p class="subtitle">
Documento institucional do domínio <strong>Billing</strong>. Unifica: catálogo de planos, preços vigentes,
assinatura (clínica/terapeuta), guardrails e entitlements (features + limits). Este material é pensado para
reduzir regressões e orientar o operador/dev quando algo “parecer impossível” (ex.: corrigir plano core sem
desativar triggers).
</p>
<div class="meta-row">
<span class="pill"><span class="dot"></span><strong>Estado:</strong> operacional (MVP)</span>
<span class="pill"><strong>Atualizado:</strong> 2026-03-01 10:43:18 UTC</span>
<span class="pill"><strong>Padrão:</strong> Supabase + Postgres + Vue/PrimeVue</span>
</div>
</header>
<aside>
<div class="toc-head">
<div class="toc-title">Sumário</div>
<p class="toc-sub">Links rápidos para leitura e execução (SQL/Seeder/Views).</p>
</div>
<nav>
<div class="nav-sec">Visão geral</div>
<a href="#01">1. Contexto e objetivos</a>
<a href="#02">2. Entidades e conceitos</a>
<div class="nav-sec">Plans & Pricing</div>
<a href="#03">3. Tabela plans e planos core</a>
<a href="#04">4. Pricing e vigência (plan_prices + views)</a>
<div class="nav-sec">Subscriptions</div>
<a href="#05">5. Subscriptions: schema e regras</a>
<a href="#06">6. Views: active_subscription</a>
<a href="#07">7. Operações: change, cancel, reactivate</a>
<a href="#08">8. Auditoria: subscription_events</a>
<div class="nav-sec">Entitlements</div>
<a href="#09">9. Features + plan_features</a>
<a href="#10">10. Views de entitlements (com limits)</a>
<div class="nav-sec">Guardrails</div>
<a href="#11">11. Triggers de proteção</a>
<a href="#12">12. Correção segura de plano core (bypass controlado)</a>
<div class="nav-sec">Seeders</div>
<a href="#13">13. Seeder idempotente: features + plan_features</a>
<a href="#14">14. Seeder idempotente: subscription de teste</a>
<div class="nav-sec">Front-end</div>
<a href="#15">15. Padrões de UI e telas (Subscriptions / Eventos)</a>
<div class="nav-sec">Apêndices</div>
<a href="#16">16. Troubleshooting (erros reais)</a>
<a href="#17">17. Checklist de validação</a>
</nav>
</aside>
<main>
<div class="content">
<section class="section" id="01">
<h2>1. Contexto e objetivos</h2>
<p>
O MVP do SaaS precisa garantir que o sistema “respeite o plano”. Para isso, o domínio Billing opera em camadas:
<strong>Plans</strong> (catálogo), <strong>Pricing</strong> (vigência), <strong>Subscriptions</strong> (plano vigente por tenant/user)
e <strong>Entitlements</strong> (features + limites).
</p>
<div class="callout info">
<strong>Regra de ouro:</strong> o front nunca deve “inferir plano” por role. O plano vigente vem de <code>subscriptions</code>
e os limites/flags vêm de <code>plan_features</code>.
</div>
</section>
<section class="section" id="02">
<h2>2. Entidades e conceitos</h2>
<div class="grid">
<div class="callout">
<strong>plans</strong>
<p>Catálogo de planos (core e custom). Guarda <code>key</code>, <code>target</code>, preço base e metadados.</p>
</div>
<div class="callout">
<strong>plan_prices</strong>
<p>Preço com vigência. Preço vigente: <span class="kbd">is_active=true</span> e <span class="kbd">active_to is null</span>.</p>
</div>
<div class="callout">
<strong>subscriptions</strong>
<p>Assinatura ativa por tenant (clínica) ou por user (terapeuta). A view escolhe a mais recente por owner.</p>
</div>
<div class="callout">
<strong>features / plan_features</strong>
<p>Mapa de capabilities e limites (<code>limits jsonb</code>). É daqui que o front deve “gatear” menus/ações.</p>
</div>
</div>
</section>
<section class="section" id="03">
<h2>3. Tabela plans e planos core</h2>
<p><strong>Planos core do MVP</strong> (devem existir e permanecer): <code>clinic_free</code>, <code>clinic_pro</code>, <code>therapist_free</code>, <code>therapist_pro</code>.</p>
<pre><code>-- estrutura confirmada (resumo)
-- plans (public)
-- id, key, name, description, is_active, price_cents, currency, billing_interval, target</code></pre>
<div class="callout warn">
<strong>Observação importante:</strong> planos core têm guardrails: não podem ser deletados e sua <code>key</code> não pode ser alterada.
</div>
</section>
<section class="section" id="04">
<h2>4. Pricing e vigência</h2>
<p>
A UI pública de preços deve consumir <code>v_public_pricing</code>. A vigência de preço vem de <code>plan_prices</code>:
preço vigente é aquele com <code>is_active=true</code> e <code>active_to is null</code>. Para planos FREE, a UI pode exibir “Grátis”
mesmo sem registro em <code>plan_prices</code>.
</p>
<div class="callout info">
<strong>Boas práticas:</strong> a tela pública não deve depender do schema “cru”. Mantenha a view como contrato.
</div>
</section>
<section class="section" id="05">
<h2>5. Subscriptions: schema e regras</h2>
<p>Schema confirmado via <code>information_schema</code> e constraints:</p>
<pre><code>-- subscriptions (public) - colunas relevantes
id uuid primary key default gen_random_uuid()
tenant_id uuid null
user_id uuid null
plan_id uuid not null references plans(id) on delete restrict
status text not null default 'active'
"interval" text null check ("interval" in ('month','year'))
current_period_start timestamptz null
current_period_end timestamptz null
plan_key text null
provider text not null default 'manual'
source text not null default 'manual'
started_at timestamptz not null default now()
created_at timestamptz not null default now()
updated_at timestamptz not null default now()</code></pre>
<div class="callout ok">
<strong>Modelagem:</strong> clínica → usa <code>tenant_id</code>. Terapeuta → usa <code>user_id</code> (com <code>tenant_id</code> nulo).
</div>
</section>
<section class="section" id="06">
<h2>6. View: v_tenant_active_subscription</h2>
<p>
Esta view define “o plano vigente” do tenant. Regra: status <code>active</code> e período ainda válido.
Escolhe a assinatura mais recente por tenant (created_at DESC).
</p>
<pre><code>select distinct on (tenant_id)
tenant_id,
plan_id,
plan_key,
"interval",
status,
current_period_start,
current_period_end,
created_at
from subscriptions s
where tenant_id is not null
and status = 'active'
and (current_period_end is null or current_period_end &gt; now())
order by tenant_id, created_at desc;</code></pre>
<div class="callout info">
<strong>Diagnóstico rápido:</strong> se views de entitlements estiverem “vazias”, primeiro verifique se existe subscription ativa nesta view.
</div>
</section>
<section class="section" id="07">
<h2>7. Operações de assinatura: change / cancel / reactivate</h2>
<p>
O front chama RPCs, mantendo a regra de ouro: “a verdade vem do banco”.
Depois de operar, a tela recarrega para refletir o estado real.
</p>
<pre><code>-- RPCs usadas no front
-- change_subscription_plan(p_subscription_id uuid, p_new_plan_id uuid)
-- cancel_subscription(p_subscription_id uuid)
-- reactivate_subscription(p_subscription_id uuid)</code></pre>
<div class="callout warn">
<strong>Nota:</strong> se o RPC atualizar apenas <code>plan_id</code>, é recomendável manter <code>plan_key</code> e <code>interval</code>
consistentes (quando for relevante), para facilitar auditoria e debugging.
</div>
</section>
<section class="section" id="08">
<h2>8. Auditoria: subscription_events</h2>
<p>
Tela “Histórico de assinaturas” é read-only e mostra até 500 eventos mais recentes. Eventos típicos:
<code>plan_changed</code>, <code>canceled</code>, <code>reactivated</code>.
</p>
<div class="callout info">
<strong>UX operacional:</strong> o histórico deve permitir navegar de volta para o owner (Subscriptions) via query <code>?q=clinic:&lt;uuid&gt;</code>.
</div>
</section>
<section class="section" id="09">
<h2>9. Features e plan_features</h2>
<p>O MVP já possui <code>features</code> com keys (ex.: <code>online_scheduling</code>, <code>reports_basic</code>, etc.) e tabela <code>plan_features</code>:</p>
<pre><code>-- plan_features(plan_id uuid not null, feature_id uuid not null,
-- enabled boolean not null default true, limits jsonb null, created_at timestamptz default now())
-- PK: (plan_id, feature_id)
-- FK: feature_id → features(id) ON DELETE CASCADE
-- FK: plan_id → plans(id) ON DELETE CASCADE</code></pre>
<div class="callout ok">
<strong>Importante:</strong> <code>limits</code> é um contrato com o front. Ex.: <code>{"max_patients":30}</code>, <code>{"sessions_per_month":40}</code>.
</div>
</section>
<section class="section" id="10">
<h2>10. Views de entitlements (com limits)</h2>
<p>Para atender o front com 1 query, criamos views “full” e “json”.</p>
<h3>10.1 v_tenant_entitlements_full</h3>
<pre><code>create or replace view public.v_tenant_entitlements_full as
select
a.tenant_id,
f.key as feature_key,
(pf.enabled = true) as allowed,
pf.limits,
a.plan_id,
p.key as plan_key
from public.v_tenant_active_subscription a
join public.plan_features pf on pf.plan_id = a.plan_id
join public.features f on f.id = pf.feature_id
join public.plans p on p.id = a.plan_id;</code></pre>
<h3>10.2 v_tenant_entitlements_json</h3>
<pre><code>create or replace view public.v_tenant_entitlements_json as
select
tenant_id,
max(plan_key) as plan_key,
jsonb_object_agg(
feature_key,
jsonb_build_object(
'allowed', allowed,
'limits', coalesce(limits, '{}'::jsonb)
)
order by feature_key
) as entitlements
from public.v_tenant_entitlements_full
group by tenant_id;</code></pre>
<div class="callout info">
<strong>Uso no front:</strong> uma única consulta retorna <code>plan_key</code> + mapa de entitlements com limits.
</div>
</section>
<section class="section" id="11">
<h2>11. Triggers de proteção (Guardrails)</h2>
<p>Triggers confirmadas em <code>public.plans</code>:</p>
<pre><code>trg_no_delete_core_plans
trg_no_change_plan_target
trg_no_change_core_plan_key</code></pre>
<h3>11.1 Funções (versões base)</h3>
<pre><code>-- guard_no_delete_core_plans(): impede deletar planos core
-- guard_no_change_core_plan_key(): impede alterar key dos planos core
-- guard_no_change_plan_target(): impede alterar target de qualquer plano</code></pre>
<div class="callout warn">
<strong>Armadilha comum:</strong> tentar “corrigir plano core” via UPDATE direto. O trigger bloqueia e isso é desejável.
</div>
</section>
<section class="section" id="12">
<h2>12. Correção segura de plano core (bypass controlado)</h2>
<p>
Caso real desta sessão: <code>clinic_free</code> estava com <code>target</code> incorreto.
O objetivo foi corrigir sem “desligar guardrails”.
</p>
<h3>12.1 Patch do guardrail para bypass por transação</h3>
<pre><code>create or replace function public.guard_no_change_plan_target()
returns trigger
language plpgsql
as $$
declare
v_bypass text;
begin
v_bypass := current_setting('app.plan_migration_bypass', true);
if v_bypass = '1' then
return new;
end if;
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
$$;</code></pre>
<h3>12.2 Função administrativa (SECURITY DEFINER)</h3>
<pre><code>create or replace 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
if p_new_target not in ('clinic','therapist') then
raise exception 'Target inválido: %', p_new_target using errcode='P0001';
end if;
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;
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;
perform set_config('app.plan_migration_bypass', '1', true);
update public.plans
set target = p_new_target
where id = v_plan_id;
end
$$;</code></pre>
<h3>12.3 Execução (caso real)</h3>
<pre><code>select public.admin_fix_plan_target('clinic_free', 'clinic');</code></pre>
<div class="callout ok">
<strong>Resultado:</strong> plano core corrigido, guardrail permanece ativo. Bypass vale apenas na transação.
</div>
<h3>12.4 Hardening recomendado</h3>
<pre><code>revoke execute on function public.admin_fix_plan_target(text, text) from public;
-- depois conceder apenas ao role administrativo apropriado</code></pre>
</section>
<section class="section" id="13">
<h2>13. Seeder idempotente: features + plan_features</h2>
<p>
O banco já continha features. O mapeamento MVP de plan_features foi validado e segue a ideia:
PRO habilita tudo e limites “altos”; FREE habilita subset e limites menores.
</p>
<pre><code>-- padrão do seed (exemplo):
-- insert into features(key, descricao, description) values (...)
-- on conflict (key) do update set ...
-- insert into plan_features(plan_id, feature_id, enabled, limits) values (...)
-- on conflict (plan_id, feature_id) do update set enabled=excluded.enabled, limits=excluded.limits;</code></pre>
<div class="callout info">
<strong>Dica operacional:</strong> manter seed idempotente evita “duplicação” e reduz bugs em ambientes de teste.
</div>
</section>
<section class="section" id="14">
<h2>14. Seeder idempotente: subscription de teste</h2>
<p>Como as views dependem de uma subscription ativa, criamos uma assinatura manual de teste para um tenant real.</p>
<pre><code>insert into public.subscriptions (
tenant_id,
plan_id,
status,
plan_key,
"interval",
current_period_start,
current_period_end,
provider,
source
)
values (
'&lt;TENANT_UUID&gt;',
(select id from public.plans where key = 'clinic_free'),
'active',
'clinic_free',
'month',
now(),
null,
'manual',
'manual'
);</code></pre>
<div class="callout ok">
<strong>Validação:</strong> após inserir, <code>v_tenant_active_subscription</code> e <code>v_tenant_entitlements_json</code> devem retornar dados.
</div>
</section>
<section class="section" id="15">
<h2>15. Front-end: padrões e telas</h2>
<p><strong>Padrões adotados nesta sessão:</strong></p>
<ul>
<li>Em arquivos Vue: <strong>script</strong><strong>template</strong><strong>style</strong>.</li>
<li>Busca com <code>FloatLabel</code> + <code>IconField</code> + <code>InputIcon</code>.</li>
<li>Telas operacionais: DataTable com paginação, estados empty e UX “foco” via <code>?q=...</code>.</li>
</ul>
<div class="callout info">
<strong>Melhorias aplicadas:</strong> Cards para “foco”, botão voltar no topo, textos mais claros e layout mais estável.
</div>
</section>
<section class="section" id="16">
<h2>16. Troubleshooting (erros reais)</h2>
<h3>16.1 “Não é permitido alterar target do plano …”</h3>
<p>Causa: trigger <code>guard_no_change_plan_target</code>. Solução: bypass controlado + função admin (seção 12).</p>
<h3>16.2 “Não é permitido alterar a key do plano padrão …”</h3>
<p>Causa: trigger <code>guard_no_change_core_plan_key</code>. Solução: não renomear core; criar novo plano se necessário.</p>
<h3>16.3 Entitlements view vazia</h3>
<p>Causa: ausência de subscription ativa em <code>v_tenant_active_subscription</code>. Solução: inserir subscription de teste (seção 14).</p>
<div class="callout warn">
<strong>Lembrete:</strong> quando algo “não retorna nada”, primeiro verifique as views-base antes de mexer no front.
</div>
</section>
<section class="section" id="17">
<h2>17. Checklist de validação</h2>
<ul>
<li><strong>Plans:</strong> core existe e está ativo; targets corretos.</li>
<li><strong>Pricing:</strong> PRO tem preço vigente (active_to null); FREE pode ficar sem price.</li>
<li><strong>Subscriptions:</strong> existe ao menos 1 assinatura ativa para testar.</li>
<li><strong>Entitlements:</strong> <code>v_tenant_entitlements_json</code> retorna mapa com <code>allowed</code> + <code>limits</code>.</li>
<li><strong>Guardrails:</strong> triggers ativas; correção de core somente via função admin.</li>
<li><strong>Front:</strong> telas operacionais OK; foco via query; layout consistente.</li>
</ul>
<div class="callout ok">
<strong>Meta:</strong> com este checklist, qualquer dev/operador consegue diagnosticar Billing em minutos.
</div>
</section>
</div>
<footer>
<div><strong>Agência PSI — Documento Mestre Billing v2.0</strong></div>
<div class="small">Gerado em 2026-03-01 10:43:18 UTC. Estrutura inspirada no padrão interno com sidebar + anchors.</div>
</footer>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,206 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agência PSI — Billing & Subscriptions v1.2</title>
<style>
body{
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
margin:0;
padding:40px;
background:#f6f8fc;
color:#0f172a;
}
h1{font-size:28px;margin-bottom:8px;}
h2{margin-top:40px;font-size:20px;}
h3{margin-top:24px;font-size:16px;}
p{line-height:1.6;color:#334155;}
pre{
background:#0f172a;
color:#e2e8f0;
padding:16px;
border-radius:12px;
overflow:auto;
}
code{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;}
.section{margin-bottom:40px;}
.badge{
display:inline-block;
padding:4px 10px;
border-radius:999px;
background:#e2e8f0;
font-size:12px;
margin-right:6px;
}
.rule{
background:#e0f2fe;
padding:14px;
border-left:4px solid #0284c7;
border-radius:10px;
margin-top:12px;
}
.warn{
background:#fef3c7;
padding:14px;
border-left:4px solid #d97706;
border-radius:10px;
margin-top:12px;
}
.danger{
background:#fee2e2;
padding:14px;
border-left:4px solid #dc2626;
border-radius:10px;
margin-top:12px;
}
.ok{
background:#dcfce7;
padding:14px;
border-left:4px solid #16a34a;
border-radius:10px;
margin-top:12px;
}
footer{
margin-top:60px;
font-size:12px;
color:#64748b;
}
</style>
</head>
<body>
<h1>Billing & Subscriptions — v1.2</h1>
<p><strong>Agência PSI</strong> — Documento consolidado da sessão técnica sobre Subscriptions, Guardrails e Seeder.</p>
<div class="section">
<h2>1. Escopo desta versão</h2>
<p>Este documento consolida tudo o que foi tratado nesta sessão:</p>
<ul>
<li>Modelagem real da tabela <code>subscriptions</code></li>
<li>Histórico via <code>subscription_events</code></li>
<li>Triggers (guardrails) de proteção</li>
<li>Views oficiais</li>
<li>Seeder completo (planos + preços + metadata pública)</li>
<li>Erros reais encontrados e solução aplicada</li>
</ul>
</div>
<div class="section">
<h2>2. Estrutura confirmada — subscriptions</h2>
<pre><code>id uuid PK
tenant_id uuid NULL
user_id uuid NULL
plan_id uuid NOT NULL
plan_key text NULL
interval text CHECK ('month','year')
status text DEFAULT 'active'
current_period_start timestamptz
current_period_end timestamptz
provider text DEFAULT 'manual'
cancel_at_period_end boolean DEFAULT false
created_at timestamptz DEFAULT now()
updated_at timestamptz DEFAULT now()</code></pre>
<div class="rule">
Assinatura de clínica exige <strong>tenant_id</strong>.
Assinatura de terapeuta pode usar <strong>user_id</strong>.
</div>
</div>
<div class="section">
<h2>3. Guardrails (Proteções de Integridade)</h2>
<h3>3.1 Impedir deletar planos core</h3>
<pre><code>create or replace function 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;
end if;
return old;
end $$;</code></pre>
<h3>3.2 Impedir alterar target</h3>
<pre><code>create or replace function guard_no_change_plan_target()
returns trigger language plpgsql as $$
begin
if new.target is distinct from old.target then
raise exception 'Não é permitido alterar target do plano.';
end if;
return new;
end $$;</code></pre>
<h3>3.3 Impedir alterar key core</h3>
<pre><code>create or replace function 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.';
end if;
return new;
end $$;</code></pre>
<div class="warn">
Esses guardrails impediram alterações indevidas quando tentamos renomear planos core.
</div>
</div>
<div class="section">
<h2>4. Views Oficiais</h2>
<p><strong>v_public_pricing</strong> — Tela pública de preços.</p>
<p><strong>v_tenant_active_subscription</strong> — Plano vigente do tenant.</p>
<p><strong>v_subscription_health</strong> — Diagnóstico de inconsistências.</p>
</div>
<div class="section">
<h2>5. Seeder Oficial (MVP)</h2>
<pre><code>insert into plans (key,name,target,is_active)
values
('clinic_free','Clinic Free','clinic',true),
('clinic_pro','Clinic Pro','clinic',true),
('therapist_free','Therapist Free','therapist',true),
('therapist_pro','Therapist Pro','therapist',true)
on conflict (key) do update set
name=excluded.name,
target=excluded.target,
is_active=excluded.is_active;</code></pre>
<div class="ok">
Seeder é idempotente. Pode rodar múltiplas vezes sem duplicar.
</div>
</div>
<div class="section">
<h2>6. Incidentes reais resolvidos</h2>
<h3>6.1 Pricing retornando null</h3>
<p>Causa: não havia preço vigente (is_active=true e active_to is null).</p>
<h3>6.2 Erro ao alterar plano padrão</h3>
<p>Causa: trigger guard_no_change_core_plan_key bloqueando alteração.</p>
<h3>6.3 Assinatura sem tenant_id</h3>
<p>Causa: regra de negócio no banco impedindo clinic sem tenant.</p>
</div>
<div class="section">
<h2>7. Diretrizes finais</h2>
<ul>
<li>Plano nunca deve ser inferido do role.</li>
<li>UI deve consumir apenas views oficiais.</li>
<li>Plano core nunca deve ser renomeado.</li>
<li>Preço sempre deve ter vigência ativa.</li>
</ul>
</div>
<footer>
Agência PSI — Billing & Subscriptions v1.2<br>
Documento gerado automaticamente.
</footer>
</body>
</html>

View File

@@ -0,0 +1,308 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8"/>
<title>Arquitetura Técnica Completa — Billing & Assinaturas | Agência PSI</title>
<style>
body{font-family:Arial,Helvetica,sans-serif;margin:40px;line-height:1.65;color:#111}
h1,h2,h3{margin-top:36px}
code,pre{background:#f4f4f4;padding:12px;border-radius:8px;display:block;overflow:auto;font-size:13px}
.section{margin-bottom:40px}
.small{font-size:13px;color:#555}
ul{margin-left:20px}
.diagram{background:#fafafa;border:1px solid #ddd;padding:16px;border-radius:8px;font-family:monospace;font-size:13px}
</style>
</head>
<body>
<h1>Arquitetura Técnica Completa — Billing & Assinaturas</h1>
<p class="small">Projeto: Agência PSI • Documento estrutural definitivo do domínio de Billing.</p>
<div class="section">
<h2>1. Visão Arquitetural Geral</h2>
<div class="diagram">
USUÁRIO
subscription_intents (VIEW unificada)
▼ (RPC activate_subscription_from_intent)
subscriptions
subscription_events (auditoria)
entitlements (derivados do plano)
</div>
<p>Separação estrutural:</p>
<ul>
<li><strong>plans</strong> → catálogo</li>
<li><strong>plan_prices</strong> → preço versionado</li>
<li><strong>subscription_intents_*</strong> → intenção pré-pagamento</li>
<li><strong>subscriptions</strong> → assinatura ativa</li>
<li><strong>subscription_events</strong> → histórico</li>
</ul>
</div>
<div class="section">
<h2>2. Estados Oficiais da Assinatura</h2>
<pre>
pending
active
past_due
suspended
cancelled
expired
</pre>
</div>
<div class="section">
<h2>3. Índice de Integridade de Preços</h2>
<pre>
create unique index if not exists uq_plan_price_active
on plan_prices (plan_id, interval, currency)
where is_active = true and active_to is null;
</pre>
</div>
<div class="section">
<h2>4. Função Completa — activate_subscription_from_intent</h2>
<pre>
CREATE OR REPLACE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid)
RETURNS subscriptions
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
declare
v_intent record;
v_sub public.subscriptions;
v_days int;
v_user_id uuid;
v_plan_id uuid;
v_target text;
begin
select * into v_intent
from public.subscription_intents
where id = p_intent_id;
if not found then
raise exception 'Intent não encontrada';
end if;
if v_intent.status <> 'paid' then
raise exception 'Intent precisa estar paid';
end if;
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';
end if;
v_target := lower(coalesce(v_target,''));
if v_target = 'clinic' and v_intent.tenant_id is null then
raise exception 'Intent clinic exige tenant_id';
end if;
if v_target = 'therapist' and v_intent.tenant_id is not null then
raise exception 'Intent therapist não deve ter tenant_id';
end if;
v_days := case when v_intent.interval = 'year' then 365 else 30 end;
v_user_id := coalesce(v_intent.created_by_user_id, v_intent.user_id);
if v_user_id is null then
raise exception 'user_id obrigatório';
end if;
if v_target = 'clinic' then
update subscriptions
set status = 'cancelled',
cancelled_at = now()
where tenant_id = v_intent.tenant_id
and status = 'active';
else
update subscriptions
set status = 'cancelled',
cancelled_at = now()
where user_id = v_user_id
and tenant_id is null
and status = 'active';
end if;
insert into subscriptions (
user_id,
plan_id,
status,
current_period_start,
current_period_end,
tenant_id,
plan_key,
interval,
provider,
started_at,
activated_at
)
values (
v_user_id,
v_plan_id,
'active',
now(),
now() + make_interval(days => v_days),
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;
return v_sub;
end;
$function$;
</pre>
</div>
<div class="section">
<h2>5. Função Completa — transition_subscription (segura)</h2>
<pre>
create or replace function public.transition_subscription(
p_subscription_id uuid,
p_to_status text,
p_reason text default null,
p_metadata jsonb default null
)
returns subscriptions
language plpgsql
security definer
as $$
declare
v_sub subscriptions;
v_uid uuid;
v_allowed boolean := false;
begin
v_uid := auth.uid();
select * into v_sub
from subscriptions
where id = p_subscription_id;
if not found then
raise exception 'Assinatura não encontrada';
end if;
if is_saas_admin() then
v_allowed := true;
end if;
if not v_allowed
and v_sub.tenant_id is null
and v_sub.user_id = v_uid then
v_allowed := true;
end if;
if not v_allowed
and v_sub.tenant_id is not null then
if exists (
select 1 from 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_allowed := true;
end if;
end if;
if not v_allowed then
raise exception 'Sem permissão';
end if;
update subscriptions
set status = p_to_status,
updated_at = now()
where id = p_subscription_id
returning * into v_sub;
insert into subscription_events (
subscription_id,
owner_id,
event_type,
created_at,
created_by,
source,
reason,
metadata
)
values (
v_sub.id,
coalesce(v_sub.tenant_id, v_sub.user_id),
'status_changed',
now(),
v_uid,
'manual_transition',
p_reason,
p_metadata
);
return v_sub;
end;
$$;
</pre>
</div>
<div class="section">
<h2>6. Máquina de Estados Recomendada</h2>
<div class="diagram">
pending → active → past_due → suspended → cancelled
expired
</div>
<p>Recomendação futura: validar allowed_transitions em tabela dedicada.</p>
</div>
<div class="section">
<h2>7. Checklist de Validação Estrutural</h2>
<ul>
<li>Intent paga gera subscription ativa</li>
<li>subscription_id vinculado corretamente</li>
<li>Cancelamento gera evento</li>
<li>Reativação preserva histórico</li>
<li>Tenant isolation validado</li>
</ul>
</div>
<div class="section">
<h2>8. Roadmap Estrutural Futuro</h2>
<ul>
<li>State machine formal com allowed_transitions</li>
<li>Automação de expiração por cron</li>
<li>Integração Stripe mantendo arquitetura</li>
<li>Health monitor automatizado</li>
</ul>
</div>
<hr/>
<p class="small">Documento técnico estrutural consolidado após implementação real validada.</p>
</body>
</html>

View File

@@ -0,0 +1,231 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Agência PSI — Billing (Arquitetura Oficial v1.1)</title>
<style>
:root{
--bg0:#f6f8fc;
--bg1:#eef2f8;
--panel:rgba(255,255,255,.85);
--border:rgba(15,23,42,.10);
--text:rgba(15,23,42,.92);
--muted:rgba(15,23,42,.70);
--accent:#2563eb;
--ok:#047857;
--warn:#b45309;
--danger:#b91c1c;
--radius:18px;
--shadow:0 18px 60px rgba(2,6,23,.10);
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
}
*{box-sizing:border-box}
body{
margin:0;
font-family:var(--sans);
background:linear-gradient(180deg,var(--bg0),var(--bg1));
color:var(--text);
}
.layout{
max-width:1100px;
margin:0 auto;
padding:40px 20px 80px;
}
header{
border:1px solid var(--border);
background:var(--panel);
border-radius:var(--radius);
padding:28px;
box-shadow:var(--shadow);
}
h1{margin:0 0 12px;font-size:30px}
h2{margin-top:40px;font-size:20px}
p{color:var(--muted);line-height:1.6}
.section{
margin-top:30px;
border:1px solid var(--border);
background:var(--panel);
padding:24px;
border-radius:var(--radius);
box-shadow:var(--shadow);
}
.rule{
border-left:4px solid var(--accent);
background:rgba(37,99,235,.08);
padding:14px;
border-radius:12px;
margin:18px 0;
}
.ok{
border-left:4px solid var(--ok);
background:rgba(4,120,87,.08);
padding:14px;
border-radius:12px;
margin:18px 0;
}
.warn{
border-left:4px solid var(--warn);
background:rgba(180,83,9,.10);
padding:14px;
border-radius:12px;
margin:18px 0;
}
.danger{
border-left:4px solid var(--danger);
background:rgba(185,28,28,.08);
padding:14px;
border-radius:12px;
margin:18px 0;
}
code,pre{font-family:var(--mono);font-size:13px}
pre{
background:rgba(2,6,23,.05);
padding:14px;
border-radius:12px;
overflow:auto;
}
table{
width:100%;
border-collapse:collapse;
margin-top:16px;
}
th,td{
border:1px solid var(--border);
padding:10px;
font-size:13px;
}
th{background:rgba(15,23,42,.04)}
footer{
text-align:center;
margin-top:50px;
font-size:12px;
color:var(--muted);
}
</style>
</head>
<body>
<div class="layout">
<header>
<h1>Billing — Arquitetura Oficial v1.1</h1>
<p>Versão 1.1 inclui procedimento formal de migração controlada para planos core, mantendo guardrails ativos e auditáveis.</p>
</header>
<div class="section">
<h2>1. Fundamentos do Domínio</h2>
<p>Billing define recursos e limites do produto. Não é camada de UI. É camada estrutural.</p>
<div class="rule"><strong>Princípio:</strong> Role (RBAC) ≠ Plano (Billing). Plano dirige features e limites; role dirige acesso.</div>
</div>
<div class="section">
<h2>2. Planos Core (MVP)</h2>
<ul>
<li>clinic_free</li>
<li>clinic_pro</li>
<li>therapist_free</li>
<li>therapist_pro</li>
</ul>
<div class="ok"><strong>Política:</strong> Planos core são estruturalmente protegidos por triggers.</div>
</div>
<div class="section">
<h2>3. Governança de Guardrails</h2>
<ul>
<li>Impedem alterar key de plano core</li>
<li>Impedem alterar target de plano core</li>
<li>Impedem deletar plano com subscription ativa</li>
</ul>
<div class="danger"><strong>Proibido:</strong> desabilitar triggers diretamente em produção.</div>
</div>
<div class="section">
<h2>4. Procedimento Oficial de Correção de Plano Core</h2>
<p>Correções estruturais devem ocorrer via função administrativa controlada.</p>
<pre>
create or replace function 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
select id into v_plan_id
from plans
where key = p_plan_key
for update;
if v_plan_id is null then
raise exception 'Plano não encontrado.';
end if;
if exists (
select 1 from subscriptions where plan_id = v_plan_id
) then
raise exception 'Plano possui subscriptions ativas.';
end if;
update plans
set target = p_new_target
where id = v_plan_id;
end;
$$;
</pre>
<div class="warn">
Esta função deve ser executada apenas por role administrativa e registrada em log de auditoria.
</div>
</div>
<div class="section">
<h2>5. Entitlements (Contrato Oficial)</h2>
<p>Entitlements são derivados exclusivamente de <code>plan_features</code>.</p>
<pre>
plan_features (
plan_id uuid,
feature_id uuid,
enabled boolean,
limits jsonb
)
</pre>
<div class="rule">
Formato oficial de limits:
{"max": 30}
{"per_month": 40}
{"max_users": 1}
</div>
</div>
<div class="section">
<h2>6. Preço Vigente</h2>
<pre>
create unique index uq_plan_price_active
on plan_prices (plan_id, interval, currency)
where is_active = true and active_to is null;
</pre>
</div>
<div class="section">
<h2>7. Onboarding</h2>
<ul>
<li>Tenant clinic → clinic_free</li>
<li>Tenant therapist → therapist_free</li>
</ul>
</div>
<footer>
Agência PSI — Billing Arquitetura Oficial v1.1
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,957 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Documentação Interna — Planos, Assinaturas e Seeder (Billing) | 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;
--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;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
}
*{ box-sizing:border-box; }
body{
margin:0;
font-family: var(--sans);
color: var(--text);
background:
radial-gradient(1200px 600px at 18% -10%, rgba(37,99,235,.10) 0%, transparent 60%),
radial-gradient(900px 520px at 90% 10%, rgba(2,132,199,.10) 0%, transparent 55%),
linear-gradient(180deg, var(--bg0), var(--bg1));
}
.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;
}
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:800; }
.toc-desc{ margin:0; font-size:12px; color:var(--muted); }
.toc{ padding:10px; }
.toc a{
display:block;
padding:8px 10px;
border-radius:12px;
font-size:13px;
color:rgba(15,23,42,.88);
text-decoration:none;
}
.toc a:hover{ background:rgba(37,99,235,.06); }
main{
border:1px solid var(--border);
background:var(--panel);
backdrop-filter: blur(6px);
border-radius:var(--radius);
box-shadow:var(--shadow);
overflow:hidden;
}
.section{ padding:18px; border-top:1px solid var(--border); }
.section:first-child{ border-top:none; }
h2{ margin:0 0 10px; font-size:18px; }
h3{ margin:12px 0 8px; font-size:14px; color:rgba(15,23,42,.86); letter-spacing:.01em; }
p{ margin:0 0 10px; color:var(--muted); line-height:1.65; }
ul{ margin:10px 0 0 18px; color:var(--muted); }
li{ margin:6px 0; }
.card{
border:1px solid var(--border);
background:rgba(255,255,255,.72);
border-radius:var(--radius);
padding:14px;
}
.rule{
border-left:4px solid var(--accent);
background:rgba(37,99,235,.08);
padding:12px;
border-radius:12px;
margin:12px 0;
color: rgba(15,23,42,.82);
}
.ok{
border-left:4px solid var(--ok);
background:rgba(4,120,87,.08);
padding:12px;
border-radius:12px;
margin:12px 0;
color: rgba(15,23,42,.82);
}
.warn{
border-left:4px solid var(--warn);
background:rgba(180,83,9,.10);
padding:12px;
border-radius:12px;
margin:12px 0;
color: rgba(15,23,42,.82);
}
.danger{
border-left:4px solid var(--danger);
background:rgba(185,28,28,.08);
padding:12px;
border-radius:12px;
margin:12px 0;
color: rgba(15,23,42,.82);
}
.table{
width:100%;
border-collapse:separate;
border-spacing:0;
margin-top:10px;
border:1px solid var(--border);
border-radius:var(--radius);
overflow:hidden;
background: rgba(255,255,255,.72);
}
.table th, .table td{
padding:10px 12px;
border-bottom:1px solid rgba(15,23,42,.08);
font-size:13px;
color:rgba(15,23,42,.88);
vertical-align: top;
}
.table th{
background:rgba(15,23,42,.03);
font-weight:800;
color: rgba(15,23,42,.72);
}
.table tr:last-child td{ border-bottom:none; }
code, pre{ font-family:var(--mono); font-size:12px; }
pre{
background:rgba(2,6,23,.04);
border:1px solid var(--border);
border-radius:var(--radius);
padding:12px;
margin-top:10px;
overflow:auto;
line-height: 1.55;
color: rgba(15,23,42,.90);
}
.pill{
display:inline-block;
padding:6px 10px;
border-radius:999px;
border:1px solid var(--border);
background:rgba(255,255,255,.72);
font-size:12px;
margin:4px 6px 0 0;
color: rgba(15,23,42,.78);
}
.grid2{
display:grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.kv{
display:flex;
gap:10px;
align-items:flex-start;
justify-content:space-between;
border:1px solid rgba(15,23,42,.08);
background:rgba(255,255,255,.72);
border-radius:14px;
padding:12px;
}
.kv b{ color: rgba(15,23,42,.88); }
.kv span{ color: var(--muted); font-size:12px; }
.path{
display:inline-block;
padding:3px 8px;
border-radius:10px;
border:1px solid rgba(15,23,42,.12);
background:rgba(255,255,255,.72);
color:rgba(15,23,42,.88);
font-family:var(--mono);
font-size:12px;
margin:2px 0;
white-space: nowrap;
}
footer{
grid-column:1 / -1;
margin-top:14px;
text-align:center;
font-size:12px;
color:var(--muted2);
}
@media (max-width: 980px){
.layout{ grid-template-columns:1fr; }
aside{ position:relative; top:0; }
.grid2{ grid-template-columns: 1fr; }
}
@media print{
header, aside, main{ box-shadow:none; }
.section{ page-break-inside:avoid; }
body{ background:white; }
main, aside{ background:white; }
.rule,.ok,.warn,.danger{ background:white; }
}
</style>
</head>
<body>
<div class="layout">
<header>
<p class="kicker">Agência PSI • Documento interno</p>
<h1>Planos, Assinaturas e Seeder — Billing (MVP)</h1>
<p class="subtitle">
Documentação interna do <strong>domínio de Billing</strong> do SaaS multi-tenant (Agência PSI),
cobrindo <strong>modelo de dados</strong>, <strong>views oficiais</strong>, <strong>catálogo de planos</strong>,
<strong>princípios de produto</strong> e um <strong>seeder idempotente</strong> para instalação nova.
O objetivo é impedir divergência entre <em>UI</em>, <em>backend</em> e <em>banco</em> (e evitar pricing nulo, upgrade quebrado e gating inconsistente).<br><br><strong>Atualizado em:</strong> 2026-03-01 (após validações reais do schema e execução do seeder).
</p>
</header>
<aside>
<div class="toc-head">
<div class="toc-title">Sumário</div>
<p class="toc-desc">Navegação rápida entre as seções.</p>
</div>
<nav class="toc">
<a href="#1-visao-geral">1. Visão geral do domínio</a>
<a href="#2-principios">2. Princípios e decisões</a>
<a href="#3-conceitos">3. Conceitos: role vs target vs plano vs feature</a>
<a href="#4-modelo">4. Modelo de dados (Postgres/Supabase)</a>
<a href="#5-views">5. Views oficiais (fonte de verdade)</a>
<a href="#6-catalogo">6. Catálogo de Planos (MVP)</a>
<a href="#7-precos">7. Preços (MVP) e vigência</a>
<a href="#8-seeder">8. Seeder (nova instalação) — SQL idempotente</a>
<a href="#9-onboarding">9. Onboarding & Upgrade (fluxo)</a>
<a href="#10-runbook">10. Operação (runbook rápido)</a>
<a href="#11-qa">11. Checklist de QA</a>
<a href="#12-prompt">12. Prompt Mestre — continuação (Billing)</a>
<a href="#13-tags">Tags</a>
</nav>
</aside>
<main>
<section class="section" id="1-visao-geral">
<h2>1. Visão geral do domínio</h2>
<p>
O Billing define <strong>o que pode</strong> e <strong>o quanto pode</strong> dentro do produto.
Ele não é “uma tela de preço”: é a camada que decide
<strong>limites</strong> (quantidade), <strong>habilitações</strong> (booleanos) e <strong>estado de assinatura</strong>.
</p>
<div class="rule">
<strong>Definição operacional:</strong> o Billing é composto por (1) catálogo de planos, (2) preços vigentes,
(3) assinatura ativa por tenant/usuário, e (4) entitlements derivados do plano.
</div>
<div class="ok">
<strong>Objetivo do MVP:</strong> todo mundo começa no <strong>FREE</strong> (clínica e terapeuta).
Paciente não é pagante; o “portal do paciente” é um recurso habilitado pelo plano do terapeuta/clínica.
</div>
</section>
<section class="section" id="2-principios">
<h2>2. Princípios e decisões</h2>
<ul>
<li><strong>Separação rígida</strong>: Role (RBAC) não é Plano (Billing). Plano define recursos; role define permissões de acesso.</li>
<li><strong>Planos por target</strong>: existe plano de <code>clinic</code> e plano de <code>therapist</code>. Isso impede aplicar plano errado em outro tipo de conta.</li>
<li><strong>Tudo começa gratuito</strong>: criação de tenant atribui automaticamente um plano <code>*_free</code>.</li>
<li><strong>Pricing público por View</strong>: a UI de preços deve consumir <code>v_public_pricing</code> (não montar preço manual no front).</li>
<li><strong>Preço é temporal</strong>: preço tem vigência (<code>active_from</code>/<code>active_to</code>) e um “ativo atual”.</li>
<li><strong>Seeder é padrão</strong>: nova instalação do banco deve nascer com os 4 planos do MVP + public metadata + preços PRO.</li>
</ul>
<div class="warn">
<strong>Problema real observado:</strong> a view <code>v_public_pricing</code> retornou preços <code>null</code> porque havia histórico em <code>plan_prices</code> mas nenhum registro vigente (todos com <code>is_active=false</code> e <code>active_to</code> preenchido).
</div>
</section>
<section class="section" id="3-conceitos">
<h2>3. Conceitos: role vs target vs plano vs feature</h2>
<div class="card">
<div class="grid2">
<div class="kv"><b>Role (RBAC)</b><span>permissão de UI/rotas (clinic_admin, therapist, patient etc.)</span></div>
<div class="kv"><b>Target (produto)</b><span>tipo de conta: <code>clinic</code> ou <code>therapist</code></span></div>
<div class="kv"><b>Plano (billing)</b><span>free/pro por target; é o “pacote” contratado</span></div>
<div class="kv"><b>Feature / Limite</b><span>entitlements: booleanos e limites numéricos derivados do plano</span></div>
</div>
</div>
<h3>3.1 Regra do produto: “um usuário pode ser paciente e terapeuta”</h3>
<p>
Essa regra é de <strong>identidade</strong> (um mesmo <em>user</em> pode estar em múltiplos contextos),
mas o plano é aplicado ao <strong>tenant</strong> (clínica/terapeuta). Assim, um usuário pode:
</p>
<ul>
<li>estar em um tenant therapist (com <code>therapist_free/pro</code>)</li>
<li>estar em um tenant clinic (com <code>clinic_free/pro</code>)</li>
<li>acessar portal de paciente como consumidor do serviço (sem plano próprio)</li>
</ul>
<div class="rule">
<strong>Consequência:</strong> plano nunca deve ser inferido do role.
O role dirige menus/rotas; o plano dirige features/limites.
</div>
</section>
<section class="section" id="4-modelo">
<h2>4. Modelo de dados (Postgres/Supabase)</h2>
<h3>4.1 Tabelas mapeadas (schema: public)</h3>
<table class="table">
<thead>
<tr>
<th>Tabela</th>
<th>Responsabilidade</th>
<th>Observações práticas</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>plans</code></td>
<td>Catálogo interno de planos (id, <code>key</code>, <code>target</code>, flags e campos legados de preço)</td>
<td><strong>Não</strong> usar <code>plans.price_cents</code> como preço público; é legado/fallback.</td>
</tr>
<tr>
<td><code>plan_prices</code></td>
<td>Preços por intervalo e moeda, com vigência</td>
<td>Fonte do valor monetário; a view pública agrega mensal/anual.</td>
</tr>
<tr>
<td><code>plan_features</code></td>
<td>Entitlements por plano (limites e habilitações)</td>
<td>Define o que o produto permite no runtime (gating).</td>
</tr>
<tr>
<td><code>plan_public</code></td>
<td>Marketing/metadata do plano (nome público, descrição, badge, destaque, visibilidade)</td>
<td>Direciona a tela de preços e o “tom” comercial.</td>
</tr>
<tr>
<td><code>plan_public_bullets</code></td>
<td>Bullets de venda por plano</td>
<td>Lista simples; a view pode agregá-las em array.</td>
</tr>
<tr>
<td><code>subscriptions</code></td>
<td>Assinatura ativa (por tenant ou user) e status</td>
<td>Fonte de verdade do plano vigente do tenant.</td>
</tr>
<tr>
<td><code>subscription_events</code></td>
<td>Histórico de mudanças (old/new plan)</td>
<td>Útil para auditoria e debug de upgrades.</td>
</tr>
<tr>
<td><code>subscription_intents</code></td>
<td>Intenção/checkout pendente</td>
<td>Controla upgrade antes de virar subscription.</td>
</tr>
<tr>
<td><code>entitlements_invalidation</code></td>
<td>Invalidação de cache de entitlements</td>
<td>Garante refresh quando plano muda.</td>
</tr>
</tbody>
</table>
<h3>4.2 Padrão de “preço vigente”</h3>
<div class="warn">
<strong>Armada clássica:</strong> se não existir pelo menos 1 preço vigente por <code>(plan_id, interval, currency)</code>,
a tela de pricing pode retornar <code>null</code> e o checkout fica sem referência.
</div>
<pre><code>-- Um preço é considerado vigente quando:
-- is_active = true
-- AND active_to IS NULL
-- AND now() >= active_from (se active_from existir)
</code></pre>
</section>
<section class="section" id="5-views">
<h2>5. Views oficiais (fonte de verdade)</h2>
<h3>5.1 View pública de pricing (UI deve consumir)</h3>
<div class="rule">
<strong>UI MUST:</strong> a tela de preços deve consultar <code>v_public_pricing</code>.
Evitar compor preços no front com join manual, pois isso cria divergência e bugs silenciosos.
</div>
<pre><code>select
plan_key,
plan_name,
public_name,
public_description,
badge,
is_featured,
is_visible,
sort_order,
monthly_cents,
yearly_cents,
monthly_currency,
yearly_currency,
bullets,
plan_target
from v_public_pricing
order by plan_target, sort_order;</code></pre>
<h3>5.2 View de preços ativos (infra/diagnóstico)</h3>
<pre><code>select *
from v_plan_active_prices
order by plan_id;</code></pre>
<h3>5.3 View de assinatura do tenant (gating/RBAC por plano)</h3>
<pre><code>select *
from v_tenant_active_subscription;</code></pre>
<h3>5.4 View de saúde de assinaturas (debug)</h3>
<pre><code>select *
from v_subscription_health
where status &lt;&gt; 'healthy';</code></pre>
</section>
<section class="section" id="6-catalogo">
<h2>6. Catálogo de Planos (MVP)</h2>
<div class="ok">
<strong>Decisão fechada:</strong> MVP com 4 planos (2 targets × free/pro).
Os planos antigos (ex.: <code>pro</code>, <code>plano_2</code>) podem ser descontinuados e ficar invisíveis.
</div>
<table class="table">
<thead>
<tr>
<th>plan_key</th>
<th>target</th>
<th>Tipo</th>
<th>Objetivo</th>
<th>Notas de produto</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>clinic_free</code></td>
<td><code>clinic</code></td>
<td>FREE</td>
<td>Entrada de clínicas pequenas (começar sem cartão)</td>
<td>Usável, mas com teto claro para gerar upgrade natural.</td>
</tr>
<tr>
<td><code>clinic_pro</code></td>
<td><code>clinic</code></td>
<td>PRO</td>
<td>Clínica completa</td>
<td>Habilita secretária, relatórios, automações etc. (conforme evolução).</td>
</tr>
<tr>
<td><code>therapist_free</code></td>
<td><code>therapist</code></td>
<td>FREE</td>
<td>Entrada de terapeuta solo</td>
<td>Permite operar, mas limita escala (pacientes/sessões).</td>
</tr>
<tr>
<td><code>therapist_pro</code></td>
<td><code>therapist</code></td>
<td>PRO</td>
<td>Profissional estabelecido</td>
<td>Expande limites e libera automações/relatórios conforme roadmap.</td>
</tr>
</tbody>
</table>
<h3>6.1 Limites sugeridos (MVP — ajustável)</h3>
<table class="table">
<thead>
<tr>
<th>Entitlement</th>
<th>clinic_free</th>
<th>clinic_pro</th>
<th>therapist_free</th>
<th>therapist_pro</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>therapists_limit</code></td>
<td>1</td>
<td>ilimitado</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>patients_limit</code></td>
<td>30</td>
<td>ilimitado</td>
<td>10</td>
<td>ilimitado</td>
</tr>
<tr>
<td><code>sessions_month_limit</code></td>
<td>100</td>
<td>ilimitado</td>
<td>40</td>
<td>ilimitado</td>
</tr>
<tr>
<td><code>secretary_enabled</code></td>
<td>false</td>
<td>true</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>reports_enabled</code></td>
<td>false</td>
<td>true</td>
<td>false</td>
<td>true</td>
</tr>
<tr>
<td><code>reminders_enabled</code></td>
<td>false</td>
<td>true</td>
<td>false</td>
<td>true</td>
</tr>
<tr>
<td><code>patient_portal_enabled</code></td>
<td>true</td>
<td>true</td>
<td>true</td>
<td>true</td>
</tr>
</tbody>
</table>
<div class="warn">
<strong>Observação:</strong> nomes de entitlements dependem da sua tabela de <code>features</code> (se existir).
A lógica do seeder abaixo separa “chaves sugeridas” da implementação final.
</div>
</section>
<section class="section" id="7-precos">
<h2>7. Preços (MVP) e vigência</h2>
<h3>7.1 Preços sugeridos</h3>
<div class="card">
<ul>
<li><strong>clinic_free</strong>: Grátis (sem preço, ou <code>0</code> se o front exigir número)</li>
<li><strong>clinic_pro</strong>: mensal R$ 149 (<code>14900</code>), anual R$ 1490 (<code>149000</code>)</li>
<li><strong>therapist_free</strong>: Grátis (sem preço, ou <code>0</code>)</li>
<li><strong>therapist_pro</strong>: mensal R$ 49 (<code>4900</code>), anual R$ 490 (<code>49000</code>)</li>
</ul>
</div>
<h3>7.2 Regras de vigência</h3>
<div class="rule">
<strong>Regra recomendada:</strong> 1 preço vigente por <code>(plan_id, interval, currency)</code>.
Para prevenir inconsistência, criar índice único parcial.
</div>
<pre><code>create unique index if not exists uq_plan_price_active
on plan_prices (plan_id, interval, currency)
where is_active = true and active_to is null;</code></pre>
<div class="danger">
<strong>Anti-padrão:</strong> encerrar todos preços e esquecer de inserir os novos. Resultado: <code>v_public_pricing</code> com <code>null</code>.
</div>
</section>
<section class="section" id="8-seeder">
<h2>8. Seeder (nova instalação) — SQL idempotente</h2>
<div class="ok">
<strong>Objetivo do seeder:</strong> instalar (1) planos, (2) metadata pública, (3) bullets, (4) preços PRO vigentes
e (opcional) (5) entitlements iniciais.
O script deve ser <strong>idempotente</strong>: rodar várias vezes sem duplicar registros.
</div>
<h3>8.1 Convenções do seeder</h3>
<ul>
<li>Usar <code>plans.key</code> como chave estável (única). A view pública expõe isso como <code>plan_key</code>.</li>
<li>Para inserts, preferir <code>insert ... on conflict ... do update</code> quando houver unique constraint.</li>
<li>Para preços: encerrar preço vigente anterior e inserir um novo (ou atualizar, conforme sua política).</li>
<li>Manter <code>source='manual'</code> no MVP (provider pode entrar depois com Stripe).</li>
</ul>
<h3>8.2 Seeder completo (MVP)</h3>
<pre><code>-- ============================================================
-- SEEDER — BILLING (MVP) • SCHEMA REAL (confirmado)
-- Planos finais: clinic_free, clinic_pro, therapist_free, therapist_pro
-- Observação: v_public_pricing expõe (plan_key/plan_target), mas na tabela base é (plans.key / plans.target).
-- ============================================================
-- 0) Proteção: 1 preço vigente por (plan_id, interval, currency)
create unique index if not exists uq_plan_price_active
on plan_prices (plan_id, interval, currency)
where is_active = true and active_to is null;
-- 1) Plans (public.plans) — usa colunas reais: key, name, target
insert into plans (key, name, description, is_active, price_cents, currency, billing_interval, target)
values
(&#x27;clinic_free&#x27;, &#x27;CLINIC FREE&#x27;, &#x27;Plano gratuito para clínicas iniciarem.&#x27;, true, 0, &#x27;BRL&#x27;, &#x27;month&#x27;, &#x27;clinic&#x27;),
(&#x27;clinic_pro&#x27;, &#x27;CLINIC PRO&#x27;, &#x27;Plano completo para clínicas.&#x27;, true, 14900, &#x27;BRL&#x27;, &#x27;month&#x27;, &#x27;clinic&#x27;),
(&#x27;therapist_free&#x27;, &#x27;THERAPIST FREE&#x27;, &#x27;Plano gratuito para terapeutas.&#x27;, true, 0, &#x27;BRL&#x27;, &#x27;month&#x27;, &#x27;therapist&#x27;),
(&#x27;therapist_pro&#x27;, &#x27;THERAPIST PRO&#x27;, &#x27;Plano completo para terapeutas.&#x27;, true, 4900, &#x27;BRL&#x27;, &#x27;month&#x27;, &#x27;therapist&#x27;)
on conflict (key) do update
set name = excluded.name,
description = excluded.description,
is_active = excluded.is_active,
price_cents = excluded.price_cents,
currency = excluded.currency,
billing_interval = excluded.billing_interval,
target = excluded.target;
-- 2) Plan public (public.plan_public) — metadata de pricing
with p as (
select id, key from plans
where key in (&#x27;clinic_free&#x27;,&#x27;clinic_pro&#x27;,&#x27;therapist_free&#x27;,&#x27;therapist_pro&#x27;)
)
insert into plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
select
id,
case key
when &#x27;clinic_free&#x27; then &#x27;Clínica — Free&#x27;
when &#x27;clinic_pro&#x27; then &#x27;Clínica — PRO&#x27;
when &#x27;therapist_free&#x27; then &#x27;Terapeuta — Free&#x27;
when &#x27;therapist_pro&#x27; then &#x27;Terapeuta — PRO&#x27;
end,
case key
when &#x27;clinic_free&#x27; then &#x27;Para clínicas pequenas começarem sem cartão.&#x27;
when &#x27;clinic_pro&#x27; then &#x27;Para clínicas que querem recursos completos.&#x27;
when &#x27;therapist_free&#x27; then &#x27;Para começar e organizar sua prática.&#x27;
when &#x27;therapist_pro&#x27; then &#x27;Para expandir com automações e escala.&#x27;
end,
case key
when &#x27;clinic_free&#x27; then &#x27;Grátis&#x27;
when &#x27;therapist_free&#x27; then &#x27;Grátis&#x27;
else null
end,
case key
when &#x27;clinic_pro&#x27; then true
when &#x27;therapist_pro&#x27; then true
else false
end,
true,
case key
when &#x27;clinic_free&#x27; then 10
when &#x27;clinic_pro&#x27; then 20
when &#x27;therapist_free&#x27; then 10
when &#x27;therapist_pro&#x27; then 20
end
from p
on conflict (plan_id) do update
set public_name = excluded.public_name,
public_description = excluded.public_description,
badge = excluded.badge,
is_featured = excluded.is_featured,
is_visible = excluded.is_visible,
sort_order = excluded.sort_order;
-- 3) Bullets (public.plan_public_bullets) — reset simples para MVP
delete from plan_public_bullets
where plan_id in (select id from plans where key in (&#x27;clinic_free&#x27;,&#x27;clinic_pro&#x27;,&#x27;therapist_free&#x27;,&#x27;therapist_pro&#x27;));
insert into plan_public_bullets (plan_id, text, highlight, sort_order)
values
((select id from plans where key=&#x27;clinic_free&#x27;), &#x27;1 terapeuta incluído&#x27;, false, 10),
((select id from plans where key=&#x27;clinic_free&#x27;), &#x27;Até 30 pacientes&#x27;, false, 20),
((select id from plans where key=&#x27;clinic_free&#x27;), &#x27;Até 100 sessões/mês&#x27;, false, 30),
((select id from plans where key=&#x27;clinic_pro&#x27;), &#x27;Terapeutas ilimitados&#x27;, true, 10),
((select id from plans where key=&#x27;clinic_pro&#x27;), &#x27;Pacientes ilimitados&#x27;, true, 20),
((select id from plans where key=&#x27;clinic_pro&#x27;), &#x27;Relatórios e lembretes&#x27;, false, 30),
((select id from plans where key=&#x27;therapist_free&#x27;), &#x27;Até 10 pacientes&#x27;, false, 10),
((select id from plans where key=&#x27;therapist_free&#x27;), &#x27;Até 40 sessões/mês&#x27;, false, 20),
((select id from plans where key=&#x27;therapist_free&#x27;), &#x27;Portal do paciente&#x27;, false, 30),
((select id from plans where key=&#x27;therapist_pro&#x27;), &#x27;Pacientes ilimitados&#x27;, true, 10),
((select id from plans where key=&#x27;therapist_pro&#x27;), &#x27;Sessões ilimitadas&#x27;, true, 20),
((select id from plans where key=&#x27;therapist_pro&#x27;), &#x27;Relatórios e lembretes&#x27;, false, 30);
-- 4) Preços vigentes (public.plan_prices) — somente PRO
do $$
declare
v_clinic_pro uuid;
v_therapist_pro uuid;
begin
select id into v_clinic_pro from plans where key=&#x27;clinic_pro&#x27;;
select id into v_therapist_pro from plans where key=&#x27;therapist_pro&#x27;;
update plan_prices
set is_active = false, active_to = now()
where plan_id in (v_clinic_pro, v_therapist_pro)
and is_active = true
and active_to is null;
insert into plan_prices (plan_id, currency, interval, amount_cents, is_active, active_from, active_to, source, provider, provider_price_id)
values
(v_clinic_pro, &#x27;BRL&#x27;, &#x27;month&#x27;, 14900, true, now(), null, &#x27;manual&#x27;, null, null),
(v_clinic_pro, &#x27;BRL&#x27;, &#x27;year&#x27;, 149000, true, now(), null, &#x27;manual&#x27;, null, null),
(v_therapist_pro, &#x27;BRL&#x27;, &#x27;month&#x27;, 4900, true, now(), null, &#x27;manual&#x27;, null, null),
(v_therapist_pro, &#x27;BRL&#x27;, &#x27;year&#x27;, 49000, true, now(), null, &#x27;manual&#x27;, null, null);
exception
when unique_violation then
raise notice &#x27;Preço vigente já existe para algum (plan_id, interval, currency).&#x27;;
end $$;
-- 5) (Opcional) Integridade: impedir apagar plano em uso
-- A FK subscriptions.plan_id -&gt; plans.id deve estar com ON DELETE RESTRICT.
-- Se precisar aplicar:
-- alter table public.subscriptions drop constraint if exists subscriptions_plan_id_fkey;
-- alter table public.subscriptions add constraint subscriptions_plan_id_fkey
-- foreign key (plan_id) references public.plans(id) on delete restrict;
-- 6) Validação final (deve retornar 4 planos visíveis)
select plan_key, plan_name, plan_target, monthly_cents, yearly_cents
from v_public_pricing
where is_visible = true
order by plan_target, sort_order, plan_key;</code></pre>
<div class="warn">
<strong>Nota de adaptação:</strong> o seeder acima assume certas colunas (ex.: <code>plans.plan_key</code>, <code>plans.plan_target</code>, <code>plans.is_active</code>, <code>plan_public.*</code>).
Se o seu schema tiver nomes diferentes, ajuste no primeiro uso e depois mantenha como “padrão oficial”.
</div>
</section>
<section class="section" id="8b-entitlements">
<h2>8B. Entitlements — Schema real (plan_features)</h2>
<p>
O MVP usa <code>plan_features</code> como tabela de ligação entre plano e feature. O schema confirmado é:
<code>(plan_id uuid NOT NULL, feature_id uuid NOT NULL, enabled boolean NOT NULL default true, limits jsonb NULL)</code>.
</p>
<div class="rule">
<strong>Padrão recomendado para limits (jsonb):</strong> padronizar chaves por tipo de limite para evitar ambiguidade no front/back.
Sugestão:
<code>{"max": 30}</code> (limite absoluto),
<code>{"per_month": 40}</code> (por período),
<code>{"max_users": 1}</code> (limite de assentos),
e manter <code>enabled</code> como flag binária.
</div>
<div class="warn">
<strong>Pré-requisito:</strong> para seedar entitlements, é necessário listar/definir as features na tabela de features (ex.: <code>features</code>).
Este documento mantém os limites do MVP como referência de produto; o seeder de <code>plan_features</code> deve mapear essas chaves para <code>feature_id</code> reais.
</div>
<h3>Template (exemplo) — como gravar limites</h3>
<pre><code>-- Exemplo: habilitar feature X com limite max=30 para clinic_free
insert into plan_features (plan_id, feature_id, enabled, limits)
values (
(select id from plans where key='clinic_free'),
'FEATURE_UUID_AQUI',
true,
'{"max": 30}'::jsonb
);</code></pre>
</section>
<section class="section" id="8c-regras-negocio">
<h2>8C. Regras de negócio confirmadas no banco</h2>
<div class="ok">
<strong>Regra confirmada:</strong> inserir subscription de <code>clinic_*</code> exige <code>tenant_id</code>.
Em testes, uma tentativa de inserir assinatura de clínica sem tenant resultou em erro:
<em>“Assinatura clinic exige tenant_id.”</em>
</div>
<div class="rule">
<strong>Consequência:</strong> assinatura de clínica é “por tenant”; assinatura de terapeuta pode ser por <code>tenant_id</code> ou <code>user_id</code>,
conforme sua arquitetura — mas o banco já impõe pelo menos o caso de clínica.
</div>
</section>
<section class="section" id="9-onboarding">
<h2>9. Onboarding & Upgrade (fluxo)</h2>
<h3>9.1 Onboarding (criação de tenant)</h3>
<ul>
<li>Ao criar um tenant <code>clinic</code> → atribuir automaticamente <code>clinic_free</code>.</li>
<li>Ao criar um tenant <code>therapist</code> → atribuir automaticamente <code>therapist_free</code>.</li>
<li>O plano deve ser a fonte de verdade para habilitar recursos (entitlements store).</li>
</ul>
<h3>9.2 Upgrade</h3>
<div class="rule">
Upgrade é troca de plano na assinatura: <code>*_free → *_pro</code>.
O sistema deve invalidar entitlements e atualizar cache (via <code>entitlements_invalidation</code> ou mecanismo equivalente).
</div>
<h3>9.3 Downgrade/expiração</h3>
<p>
No MVP, a regra segura é: ao expirar, <strong>bloquear novas criações premium</strong>,
mas <strong>não apagar dados</strong>. Apenas retira capacidade.
</p>
</section>
<section class="section" id="10-runbook">
<h2>10. Operação (runbook rápido)</h2>
<div class="card">
<h3>Incidente comum: Pricing mostra preços nulos</h3>
<ol style="margin:10px 0 0 18px; color: var(--muted); line-height:1.65;">
<li>Rodar <code>select * from v_public_pricing;</code></li>
<li>Rodar <code>select * from plan_prices where plan_id = ... order by created_at desc;</code></li>
<li>Confirmar existência de preço vigente: <code>is_active=true</code> e <code>active_to is null</code></li>
<li>Se não existir, inserir preços PRO vigentes (month/year) e validar view novamente.</li>
</ol>
</div>
<div class="card" style="margin-top:12px;">
<h3>Incidente comum: Plano aparece errado para um tenant</h3>
<ol style="margin:10px 0 0 18px; color: var(--muted); line-height:1.65;">
<li>Verificar <code>v_tenant_active_subscription</code> para o tenant em questão.</li>
<li>Verificar se o plano tem <code>plan_target</code> correto.</li>
<li>Verificar se o guard/menu não está inferindo plano do role (anti-padrão).</li>
<li>Invalidar entitlements e reavaliar.</li>
</ol>
</div>
</section>
<section class="section" id="11-qa">
<h2>11. Checklist de QA</h2>
<ul>
<li><strong>Seeder</strong>: rodar duas vezes e confirmar que não duplica registros.</li>
<li><strong>Pricing</strong>: <code>v_public_pricing</code> retorna 4 planos, com preços preenchidos para PRO.</li>
<li><strong>Upgrade</strong>: trocar plano e confirmar mudança de entitlements no runtime.</li>
<li><strong>FREE</strong>: criação de tenant atribui automaticamente plano free correto.</li>
<li><strong>Target</strong>: clínica nunca recebe plano therapist (e vice-versa).</li>
<li><strong>Vigência</strong>: inserir novo preço e confirmar que o antigo foi encerrado (active_to preenchido).</li>
</ul>
</section>
<section class="section" id="12-prompt">
<h2>12. Prompt Mestre — Continuidade do Billing (Planos/Assinaturas)</h2>
<div class="rule">
Sempre que iniciar um novo chat sobre Billing/Planos, copie e cole este prompt.
Ele representa o estado oficial do domínio e da estrutura do banco para o MVP.
</div>
<pre><code>
Estou desenvolvendo um SaaS clínico multi-tenant usando Supabase (Postgres + RLS + Views)
com planos e assinaturas.
══════════════════════════════════════
📦 Domínio: Billing / Planos
══════════════════════════════════════
Decisões do MVP:
- Tudo começa grátis (clinic e therapist).
- Paciente não tem plano (portal do paciente é feature do plano do therapist/clinic).
- Plano (billing) NÃO é role (RBAC). Role dirige menus/rotas; plano dirige features/limites.
- Planos por target: clinic e therapist.
Catálogo de planos (MVP):
- clinic_free
- clinic_pro
- therapist_free
- therapist_pro
Views fonte de verdade:
- v_public_pricing (tela de preços)
- v_plan_active_prices (infra)
- v_tenant_active_subscription (gating por tenant)
- v_subscription_health (debug)
Tabelas principais:
- plans (colunas reais: key, target, ...)
- plan_prices (tem vigência; preço vigente: is_active=true e active_to is null; a UI usa v_plan_active_prices)
- plan_public + plan_public_bullets (marketing)
- plan_features (entitlements)
- subscriptions (+ events, intents)
- entitlements_invalidation
Preços sugeridos (MVP):
- clinic_pro: 14900/mês e 149000/ano (BRL)
- therapist_pro: 4900/mês e 49000/ano (BRL)
- free: grátis (pode manter sem preços)
Problema já observado:
- v_public_pricing retornou null quando plan_prices tinha histórico mas não tinha preço vigente.
Estado atual (confirmado):
- Apenas 4 planos existem (clinic_free/clinic_pro/therapist_free/therapist_pro)
Objetivo do próximo passo:
- Seedar plan_features (entitlements) mapeando features -> feature_id e limits jsonb para nova instalação com os 4 planos + public metadata + preços PRO vigentes.
</code></pre>
<div class="ok">
Este prompt deve ser tratado como contexto estrutural completo do Billing no MVP.
Qualquer solução proposta deve respeitar essa organização.
</div>
</section>
<section class="section" id="13-tags">
<h2>Tags</h2>
<span class="pill">#Billing</span>
<span class="pill">#Planos</span>
<span class="pill">#Pricing</span>
<span class="pill">#Seeder</span>
<span class="pill">#Supabase</span>
<span class="pill">#Postgres</span>
<span class="pill">#MultiTenant</span>
<span class="pill">#Entitlements</span>
<span class="pill">#Subscriptions</span>
<span class="pill">#v_public_pricing</span>
<span class="pill">#MVP</span>
</section>
</main>
<footer>
Agência PSI • Documentação interna • Billing (Planos/Assinaturas/Seeder)
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,612 @@
<!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>

View File

@@ -0,0 +1,414 @@
-- ============================================================
-- SUPERVISOR — Fase 1
-- Aplicar no Supabase SQL Editor (em ordem)
-- ============================================================
-- ────────────────────────────────────────────────────────────
-- 1. tenants.kind → adiciona 'supervisor'
-- ────────────────────────────────────────────────────────────
ALTER TABLE public.tenants
DROP CONSTRAINT IF EXISTS tenants_kind_check;
ALTER TABLE public.tenants
ADD CONSTRAINT tenants_kind_check
CHECK (kind = ANY (ARRAY[
'therapist',
'clinic_coworking',
'clinic_reception',
'clinic_full',
'clinic',
'saas',
'supervisor' -- ← novo
]));
-- ────────────────────────────────────────────────────────────
-- 2. plans.target → adiciona 'supervisor'
-- ────────────────────────────────────────────────────────────
ALTER TABLE public.plans
DROP CONSTRAINT IF EXISTS plans_target_check;
ALTER TABLE public.plans
ADD CONSTRAINT plans_target_check
CHECK (target = ANY (ARRAY[
'patient',
'therapist',
'clinic',
'supervisor' -- ← novo
]));
-- ────────────────────────────────────────────────────────────
-- 3. plans.max_supervisees — limite de supervisionados
-- ────────────────────────────────────────────────────────────
ALTER TABLE public.plans
ADD COLUMN IF NOT EXISTS max_supervisees integer DEFAULT NULL;
COMMENT ON COLUMN public.plans.max_supervisees IS
'Limite de terapeutas que podem ser supervisionados. Apenas para planos target=supervisor. NULL = sem limite.';
-- ────────────────────────────────────────────────────────────
-- 4. Planos supervisor_free e supervisor_pro
-- ────────────────────────────────────────────────────────────
INSERT INTO public.plans (key, name, description, target, is_active, max_supervisees)
VALUES
(
'supervisor_free',
'Supervisor Free',
'Plano gratuito de supervisão. Até 3 terapeutas supervisionados.',
'supervisor',
true,
3
),
(
'supervisor_pro',
'Supervisor PRO',
'Plano profissional de supervisão. Até 20 terapeutas supervisionados.',
'supervisor',
true,
20
)
ON CONFLICT (key) DO UPDATE
SET
name = EXCLUDED.name,
description = EXCLUDED.description,
target = EXCLUDED.target,
is_active = EXCLUDED.is_active,
max_supervisees = EXCLUDED.max_supervisees;
-- ────────────────────────────────────────────────────────────
-- 5. Features de supervisor
-- ────────────────────────────────────────────────────────────
INSERT INTO public.features (key, name, descricao)
VALUES
(
'supervisor.access',
'Acesso à Supervisão',
'Acesso básico ao espaço de supervisão (sala, lista de supervisionados).'
),
(
'supervisor.invite',
'Convidar Supervisionados',
'Permite convidar terapeutas para participar da sala de supervisão.'
),
(
'supervisor.sessions',
'Sessões de Supervisão',
'Agendamento e registro de sessões de supervisão.'
),
(
'supervisor.reports',
'Relatórios de Supervisão',
'Relatórios avançados de progresso e evolução dos supervisionados.'
)
ON CONFLICT (key) DO UPDATE
SET
name = EXCLUDED.name,
descricao = EXCLUDED.descricao;
-- ────────────────────────────────────────────────────────────
-- 6. plan_features — vincula features aos planos supervisor
-- ────────────────────────────────────────────────────────────
DO $$
DECLARE
v_free_id uuid;
v_pro_id uuid;
v_f_access uuid;
v_f_invite uuid;
v_f_sessions uuid;
v_f_reports uuid;
BEGIN
SELECT id INTO v_free_id FROM public.plans WHERE key = 'supervisor_free';
SELECT id INTO v_pro_id FROM public.plans WHERE key = 'supervisor_pro';
SELECT id INTO v_f_access FROM public.features WHERE key = 'supervisor.access';
SELECT id INTO v_f_invite FROM public.features WHERE key = 'supervisor.invite';
SELECT id INTO v_f_sessions FROM public.features WHERE key = 'supervisor.sessions';
SELECT id INTO v_f_reports FROM public.features WHERE key = 'supervisor.reports';
-- supervisor_free: access + invite (limitado por max_supervisees=3)
INSERT INTO public.plan_features (plan_id, feature_id)
VALUES
(v_free_id, v_f_access),
(v_free_id, v_f_invite)
ON CONFLICT DO NOTHING;
-- supervisor_pro: tudo
INSERT INTO public.plan_features (plan_id, feature_id)
VALUES
(v_pro_id, v_f_access),
(v_pro_id, v_f_invite),
(v_pro_id, v_f_sessions),
(v_pro_id, v_f_reports)
ON CONFLICT DO NOTHING;
END;
$$;
-- ────────────────────────────────────────────────────────────
-- 7. activate_subscription_from_intent — suporte a supervisor
-- Supervisor = pessoal (user_id), sem tenant_id (igual therapist)
-- ────────────────────────────────────────────────────────────
CREATE OR REPLACE 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;
$$;
-- ────────────────────────────────────────────────────────────
-- 8. subscriptions_validate_scope — suporte a supervisor
-- ────────────────────────────────────────────────────────────
CREATE OR REPLACE 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;
$$;
-- ────────────────────────────────────────────────────────────
-- 9. subscription_intents_view_insert — suporte a supervisor
-- supervisor é roteado como therapist (tabela personal)
-- ────────────────────────────────────────────────────────────
CREATE OR REPLACE 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;
$$;
-- ────────────────────────────────────────────────────────────
-- FIM — verificação rápida
-- ────────────────────────────────────────────────────────────
SELECT key, name, target, max_supervisees
FROM public.plans
WHERE target = 'supervisor'
ORDER BY key;