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,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>