Files
agenciapsilmno/Nova-Dev-Doc/Planos/dev-documentacao-planos-seeder-v1.html
Leonardo f733db8436 ZERADO
2026-03-06 06:37:13 -03:00

958 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>