ZERADO
This commit is contained in:
957
Nova-Dev-Doc/Planos/dev-documentacao-planos-seeder-v1.html
Normal file
957
Nova-Dev-Doc/Planos/dev-documentacao-planos-seeder-v1.html
Normal 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 <> '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
|
||||
('clinic_free', 'CLINIC FREE', 'Plano gratuito para clínicas iniciarem.', true, 0, 'BRL', 'month', 'clinic'),
|
||||
('clinic_pro', 'CLINIC PRO', 'Plano completo para clínicas.', true, 14900, 'BRL', 'month', 'clinic'),
|
||||
('therapist_free', 'THERAPIST FREE', 'Plano gratuito para terapeutas.', true, 0, 'BRL', 'month', 'therapist'),
|
||||
('therapist_pro', 'THERAPIST PRO', 'Plano completo para terapeutas.', true, 4900, 'BRL', 'month', 'therapist')
|
||||
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 ('clinic_free','clinic_pro','therapist_free','therapist_pro')
|
||||
)
|
||||
insert into plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
|
||||
select
|
||||
id,
|
||||
case key
|
||||
when 'clinic_free' then 'Clínica — Free'
|
||||
when 'clinic_pro' then 'Clínica — PRO'
|
||||
when 'therapist_free' then 'Terapeuta — Free'
|
||||
when 'therapist_pro' then 'Terapeuta — PRO'
|
||||
end,
|
||||
case key
|
||||
when 'clinic_free' then 'Para clínicas pequenas começarem sem cartão.'
|
||||
when 'clinic_pro' then 'Para clínicas que querem recursos completos.'
|
||||
when 'therapist_free' then 'Para começar e organizar sua prática.'
|
||||
when 'therapist_pro' then 'Para expandir com automações e escala.'
|
||||
end,
|
||||
case key
|
||||
when 'clinic_free' then 'Grátis'
|
||||
when 'therapist_free' then 'Grátis'
|
||||
else null
|
||||
end,
|
||||
case key
|
||||
when 'clinic_pro' then true
|
||||
when 'therapist_pro' then true
|
||||
else false
|
||||
end,
|
||||
true,
|
||||
case key
|
||||
when 'clinic_free' then 10
|
||||
when 'clinic_pro' then 20
|
||||
when 'therapist_free' then 10
|
||||
when 'therapist_pro' 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 ('clinic_free','clinic_pro','therapist_free','therapist_pro'));
|
||||
|
||||
insert into plan_public_bullets (plan_id, text, highlight, sort_order)
|
||||
values
|
||||
((select id from plans where key='clinic_free'), '1 terapeuta incluído', false, 10),
|
||||
((select id from plans where key='clinic_free'), 'Até 30 pacientes', false, 20),
|
||||
((select id from plans where key='clinic_free'), 'Até 100 sessões/mês', false, 30),
|
||||
|
||||
((select id from plans where key='clinic_pro'), 'Terapeutas ilimitados', true, 10),
|
||||
((select id from plans where key='clinic_pro'), 'Pacientes ilimitados', true, 20),
|
||||
((select id from plans where key='clinic_pro'), 'Relatórios e lembretes', false, 30),
|
||||
|
||||
((select id from plans where key='therapist_free'), 'Até 10 pacientes', false, 10),
|
||||
((select id from plans where key='therapist_free'), 'Até 40 sessões/mês', false, 20),
|
||||
((select id from plans where key='therapist_free'), 'Portal do paciente', false, 30),
|
||||
|
||||
((select id from plans where key='therapist_pro'), 'Pacientes ilimitados', true, 10),
|
||||
((select id from plans where key='therapist_pro'), 'Sessões ilimitadas', true, 20),
|
||||
((select id from plans where key='therapist_pro'), 'Relatórios e lembretes', 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='clinic_pro';
|
||||
select id into v_therapist_pro from plans where key='therapist_pro';
|
||||
|
||||
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, 'BRL', 'month', 14900, true, now(), null, 'manual', null, null),
|
||||
(v_clinic_pro, 'BRL', 'year', 149000, true, now(), null, 'manual', null, null),
|
||||
(v_therapist_pro, 'BRL', 'month', 4900, true, now(), null, 'manual', null, null),
|
||||
(v_therapist_pro, 'BRL', 'year', 49000, true, now(), null, 'manual', null, null);
|
||||
exception
|
||||
when unique_violation then
|
||||
raise notice 'Preço vigente já existe para algum (plan_id, interval, currency).';
|
||||
end $$;
|
||||
|
||||
-- 5) (Opcional) Integridade: impedir apagar plano em uso
|
||||
-- A FK subscriptions.plan_id -> 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>
|
||||
Reference in New Issue
Block a user