ZERADO
This commit is contained in:
@@ -0,0 +1,672 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Documento Mestre — Billing (Plans, Pricing, Subscriptions, Entitlements) v2.0 | Agência PSI</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg0:#f6f8fc;
|
||||
--bg1:#eef2f8;
|
||||
--panel:rgba(255,255,255,.78);
|
||||
--panel2:rgba(255,255,255,.92);
|
||||
--border:rgba(15,23,42,.10);
|
||||
--text:rgba(15,23,42,.92);
|
||||
--muted:rgba(15,23,42,.70);
|
||||
--muted2:rgba(15,23,42,.56);
|
||||
--accent:#2563eb;
|
||||
--accent2:#4f46e5;
|
||||
--warn:#b45309;
|
||||
--danger:#b91c1c;
|
||||
--ok:#047857;
|
||||
--shadow: 0 18px 60px rgba(2,6,23,.10);
|
||||
--radius: 16px;
|
||||
--radius2: 22px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{
|
||||
margin:0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
background: radial-gradient(1200px 600px at 10% 0%, rgba(79,70,229,.10), transparent 55%),
|
||||
radial-gradient(1100px 500px at 90% 10%, rgba(37,99,235,.10), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
color:var(--text);
|
||||
}
|
||||
a{color:inherit; text-decoration:none;}
|
||||
a:hover{text-decoration:underline;}
|
||||
.layout{
|
||||
display:grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 20px;
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 42px;
|
||||
}
|
||||
header{
|
||||
grid-column: 1 / -1;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.92), rgba(255,255,255,.72));
|
||||
border-radius: var(--radius2);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.kicker{
|
||||
font-size:12px;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
color:var(--muted2);
|
||||
margin:0 0 8px;
|
||||
}
|
||||
h1{
|
||||
margin:0 0 8px;
|
||||
font-size:30px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
.subtitle{
|
||||
margin:0;
|
||||
color:var(--muted);
|
||||
max-width:980px;
|
||||
line-height:1.55;
|
||||
font-size:14px;
|
||||
}
|
||||
.meta-row{
|
||||
margin-top:12px;
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
}
|
||||
.pill{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
padding:8px 12px;
|
||||
border-radius:999px;
|
||||
border:1px solid var(--border);
|
||||
background:rgba(255,255,255,.72);
|
||||
color:var(--muted);
|
||||
font-size:12px;
|
||||
}
|
||||
.dot{
|
||||
width:8px;height:8px;border-radius:50%;
|
||||
background:var(--accent);
|
||||
box-shadow:0 0 0 4px rgba(37,99,235,.12);
|
||||
}
|
||||
aside{
|
||||
position:sticky;
|
||||
top:18px;
|
||||
align-self:start;
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel2);
|
||||
border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.toc-head{
|
||||
padding:14px;
|
||||
border-bottom:1px solid var(--border);
|
||||
background:rgba(15,23,42,.02);
|
||||
}
|
||||
.toc-title{ margin:0 0 6px; font-weight:700; font-size:14px; }
|
||||
.toc-sub{ margin:0; color:var(--muted); font-size:12px; line-height:1.45; }
|
||||
nav{ padding: 10px 6px 14px; }
|
||||
nav a{
|
||||
display:block;
|
||||
padding:10px 12px;
|
||||
margin:4px 6px;
|
||||
border-radius:12px;
|
||||
color:var(--muted);
|
||||
font-size:13px;
|
||||
border:1px solid transparent;
|
||||
}
|
||||
nav a:hover{
|
||||
background:rgba(37,99,235,.06);
|
||||
border-color:rgba(37,99,235,.12);
|
||||
color:var(--text);
|
||||
text-decoration:none;
|
||||
}
|
||||
.nav-sec{
|
||||
margin:10px 12px 6px;
|
||||
color:var(--muted2);
|
||||
font-size:11px;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
main{
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel);
|
||||
border-radius:var(--radius2);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.content{ padding: 18px 18px 22px; }
|
||||
.section{
|
||||
padding: 18px;
|
||||
border:1px solid var(--border);
|
||||
border-radius: var(--radius2);
|
||||
background: rgba(255,255,255,.80);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section h2{
|
||||
margin:0 0 10px;
|
||||
font-size:18px;
|
||||
letter-spacing:-0.01em;
|
||||
}
|
||||
.section h3{
|
||||
margin:14px 0 8px;
|
||||
font-size:14px;
|
||||
}
|
||||
.section p, .section li{
|
||||
color:var(--muted);
|
||||
line-height:1.65;
|
||||
font-size:13.5px;
|
||||
}
|
||||
.grid{
|
||||
display:grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 980px){
|
||||
.layout{ grid-template-columns: 1fr; }
|
||||
aside{ position:relative; top:auto; }
|
||||
.grid{ grid-template-columns: 1fr; }
|
||||
}
|
||||
.callout{
|
||||
border-radius: 16px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
border:1px solid var(--border);
|
||||
background: rgba(15,23,42,.02);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.callout strong{color:var(--text);}
|
||||
.callout.ok{ border-left: 4px solid var(--ok); background: rgba(4,120,87,.06); }
|
||||
.callout.warn{ border-left: 4px solid var(--warn); background: rgba(180,83,9,.08); }
|
||||
.callout.danger{ border-left: 4px solid var(--danger); background: rgba(185,28,28,.08); }
|
||||
.callout.info{ border-left: 4px solid var(--accent); background: rgba(37,99,235,.08); }
|
||||
pre{
|
||||
margin: 10px 0 0;
|
||||
padding: 14px;
|
||||
background: #0b1220;
|
||||
color:#e2e8f0;
|
||||
border-radius: 16px;
|
||||
overflow:auto;
|
||||
border: 1px solid rgba(226,232,240,.08);
|
||||
}
|
||||
code{ font-family: var(--mono); font-size:12.5px; }
|
||||
.kbd{
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255,255,255,.65);
|
||||
color: var(--text);
|
||||
}
|
||||
.hr{
|
||||
height:1px;
|
||||
background: var(--border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
footer{
|
||||
margin-top: 12px;
|
||||
padding: 14px 18px 18px;
|
||||
color: var(--muted2);
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(255,255,255,.70);
|
||||
}
|
||||
.small{ font-size:12px; color:var(--muted2); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<header>
|
||||
<p class="kicker">Documento Mestre • Billing • Agência PSI</p>
|
||||
<h1>Plans, Pricing, Subscriptions & Entitlements — v2.0</h1>
|
||||
<p class="subtitle">
|
||||
Documento institucional do domínio <strong>Billing</strong>. Unifica: catálogo de planos, preços vigentes,
|
||||
assinatura (clínica/terapeuta), guardrails e entitlements (features + limits). Este material é pensado para
|
||||
reduzir regressões e orientar o operador/dev quando algo “parecer impossível” (ex.: corrigir plano core sem
|
||||
desativar triggers).
|
||||
</p>
|
||||
<div class="meta-row">
|
||||
<span class="pill"><span class="dot"></span><strong>Estado:</strong> operacional (MVP)</span>
|
||||
<span class="pill"><strong>Atualizado:</strong> 2026-03-01 10:43:18 UTC</span>
|
||||
<span class="pill"><strong>Padrão:</strong> Supabase + Postgres + Vue/PrimeVue</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside>
|
||||
<div class="toc-head">
|
||||
<div class="toc-title">Sumário</div>
|
||||
<p class="toc-sub">Links rápidos para leitura e execução (SQL/Seeder/Views).</p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="nav-sec">Visão geral</div>
|
||||
<a href="#01">1. Contexto e objetivos</a>
|
||||
<a href="#02">2. Entidades e conceitos</a>
|
||||
|
||||
<div class="nav-sec">Plans & Pricing</div>
|
||||
<a href="#03">3. Tabela plans e planos core</a>
|
||||
<a href="#04">4. Pricing e vigência (plan_prices + views)</a>
|
||||
|
||||
<div class="nav-sec">Subscriptions</div>
|
||||
<a href="#05">5. Subscriptions: schema e regras</a>
|
||||
<a href="#06">6. Views: active_subscription</a>
|
||||
<a href="#07">7. Operações: change, cancel, reactivate</a>
|
||||
<a href="#08">8. Auditoria: subscription_events</a>
|
||||
|
||||
<div class="nav-sec">Entitlements</div>
|
||||
<a href="#09">9. Features + plan_features</a>
|
||||
<a href="#10">10. Views de entitlements (com limits)</a>
|
||||
|
||||
<div class="nav-sec">Guardrails</div>
|
||||
<a href="#11">11. Triggers de proteção</a>
|
||||
<a href="#12">12. Correção segura de plano core (bypass controlado)</a>
|
||||
|
||||
<div class="nav-sec">Seeders</div>
|
||||
<a href="#13">13. Seeder idempotente: features + plan_features</a>
|
||||
<a href="#14">14. Seeder idempotente: subscription de teste</a>
|
||||
|
||||
<div class="nav-sec">Front-end</div>
|
||||
<a href="#15">15. Padrões de UI e telas (Subscriptions / Eventos)</a>
|
||||
|
||||
<div class="nav-sec">Apêndices</div>
|
||||
<a href="#16">16. Troubleshooting (erros reais)</a>
|
||||
<a href="#17">17. Checklist de validação</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="content">
|
||||
|
||||
<section class="section" id="01">
|
||||
<h2>1. Contexto e objetivos</h2>
|
||||
<p>
|
||||
O MVP do SaaS precisa garantir que o sistema “respeite o plano”. Para isso, o domínio Billing opera em camadas:
|
||||
<strong>Plans</strong> (catálogo), <strong>Pricing</strong> (vigência), <strong>Subscriptions</strong> (plano vigente por tenant/user)
|
||||
e <strong>Entitlements</strong> (features + limites).
|
||||
</p>
|
||||
<div class="callout info">
|
||||
<strong>Regra de ouro:</strong> o front nunca deve “inferir plano” por role. O plano vigente vem de <code>subscriptions</code>
|
||||
e os limites/flags vêm de <code>plan_features</code>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="02">
|
||||
<h2>2. Entidades e conceitos</h2>
|
||||
<div class="grid">
|
||||
<div class="callout">
|
||||
<strong>plans</strong>
|
||||
<p>Catálogo de planos (core e custom). Guarda <code>key</code>, <code>target</code>, preço base e metadados.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>plan_prices</strong>
|
||||
<p>Preço com vigência. Preço vigente: <span class="kbd">is_active=true</span> e <span class="kbd">active_to is null</span>.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>subscriptions</strong>
|
||||
<p>Assinatura ativa por tenant (clínica) ou por user (terapeuta). A view escolhe a mais recente por owner.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>features / plan_features</strong>
|
||||
<p>Mapa de capabilities e limites (<code>limits jsonb</code>). É daqui que o front deve “gatear” menus/ações.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="03">
|
||||
<h2>3. Tabela plans e planos core</h2>
|
||||
<p><strong>Planos core do MVP</strong> (devem existir e permanecer): <code>clinic_free</code>, <code>clinic_pro</code>, <code>therapist_free</code>, <code>therapist_pro</code>.</p>
|
||||
<pre><code>-- estrutura confirmada (resumo)
|
||||
-- plans (public)
|
||||
-- id, key, name, description, is_active, price_cents, currency, billing_interval, target</code></pre>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Observação importante:</strong> planos core têm guardrails: não podem ser deletados e sua <code>key</code> não pode ser alterada.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="04">
|
||||
<h2>4. Pricing e vigência</h2>
|
||||
<p>
|
||||
A UI pública de preços deve consumir <code>v_public_pricing</code>. A vigência de preço vem de <code>plan_prices</code>:
|
||||
preço vigente é aquele com <code>is_active=true</code> e <code>active_to is null</code>. Para planos FREE, a UI pode exibir “Grátis”
|
||||
mesmo sem registro em <code>plan_prices</code>.
|
||||
</p>
|
||||
<div class="callout info">
|
||||
<strong>Boas práticas:</strong> a tela pública não deve depender do schema “cru”. Mantenha a view como contrato.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="05">
|
||||
<h2>5. Subscriptions: schema e regras</h2>
|
||||
<p>Schema confirmado via <code>information_schema</code> e constraints:</p>
|
||||
<pre><code>-- subscriptions (public) - colunas relevantes
|
||||
id uuid primary key default gen_random_uuid()
|
||||
tenant_id uuid null
|
||||
user_id uuid null
|
||||
plan_id uuid not null references plans(id) on delete restrict
|
||||
status text not null default 'active'
|
||||
"interval" text null check ("interval" in ('month','year'))
|
||||
current_period_start timestamptz null
|
||||
current_period_end timestamptz null
|
||||
plan_key text null
|
||||
provider text not null default 'manual'
|
||||
source text not null default 'manual'
|
||||
started_at timestamptz not null default now()
|
||||
created_at timestamptz not null default now()
|
||||
updated_at timestamptz not null default now()</code></pre>
|
||||
|
||||
<div class="callout ok">
|
||||
<strong>Modelagem:</strong> clínica → usa <code>tenant_id</code>. Terapeuta → usa <code>user_id</code> (com <code>tenant_id</code> nulo).
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="06">
|
||||
<h2>6. View: v_tenant_active_subscription</h2>
|
||||
<p>
|
||||
Esta view define “o plano vigente” do tenant. Regra: status <code>active</code> e período ainda válido.
|
||||
Escolhe a assinatura mais recente por tenant (created_at DESC).
|
||||
</p>
|
||||
<pre><code>select distinct on (tenant_id)
|
||||
tenant_id,
|
||||
plan_id,
|
||||
plan_key,
|
||||
"interval",
|
||||
status,
|
||||
current_period_start,
|
||||
current_period_end,
|
||||
created_at
|
||||
from subscriptions s
|
||||
where tenant_id is not null
|
||||
and status = 'active'
|
||||
and (current_period_end is null or current_period_end > now())
|
||||
order by tenant_id, created_at desc;</code></pre>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Diagnóstico rápido:</strong> se views de entitlements estiverem “vazias”, primeiro verifique se existe subscription ativa nesta view.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="07">
|
||||
<h2>7. Operações de assinatura: change / cancel / reactivate</h2>
|
||||
<p>
|
||||
O front chama RPCs, mantendo a regra de ouro: “a verdade vem do banco”.
|
||||
Depois de operar, a tela recarrega para refletir o estado real.
|
||||
</p>
|
||||
<pre><code>-- RPCs usadas no front
|
||||
-- change_subscription_plan(p_subscription_id uuid, p_new_plan_id uuid)
|
||||
-- cancel_subscription(p_subscription_id uuid)
|
||||
-- reactivate_subscription(p_subscription_id uuid)</code></pre>
|
||||
<div class="callout warn">
|
||||
<strong>Nota:</strong> se o RPC atualizar apenas <code>plan_id</code>, é recomendável manter <code>plan_key</code> e <code>interval</code>
|
||||
consistentes (quando for relevante), para facilitar auditoria e debugging.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="08">
|
||||
<h2>8. Auditoria: subscription_events</h2>
|
||||
<p>
|
||||
Tela “Histórico de assinaturas” é read-only e mostra até 500 eventos mais recentes. Eventos típicos:
|
||||
<code>plan_changed</code>, <code>canceled</code>, <code>reactivated</code>.
|
||||
</p>
|
||||
<div class="callout info">
|
||||
<strong>UX operacional:</strong> o histórico deve permitir navegar de volta para o owner (Subscriptions) via query <code>?q=clinic:<uuid></code>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="09">
|
||||
<h2>9. Features e plan_features</h2>
|
||||
<p>O MVP já possui <code>features</code> com keys (ex.: <code>online_scheduling</code>, <code>reports_basic</code>, etc.) e tabela <code>plan_features</code>:</p>
|
||||
<pre><code>-- plan_features(plan_id uuid not null, feature_id uuid not null,
|
||||
-- enabled boolean not null default true, limits jsonb null, created_at timestamptz default now())
|
||||
-- PK: (plan_id, feature_id)
|
||||
-- FK: feature_id → features(id) ON DELETE CASCADE
|
||||
-- FK: plan_id → plans(id) ON DELETE CASCADE</code></pre>
|
||||
|
||||
<div class="callout ok">
|
||||
<strong>Importante:</strong> <code>limits</code> é um contrato com o front. Ex.: <code>{"max_patients":30}</code>, <code>{"sessions_per_month":40}</code>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="10">
|
||||
<h2>10. Views de entitlements (com limits)</h2>
|
||||
<p>Para atender o front com 1 query, criamos views “full” e “json”.</p>
|
||||
|
||||
<h3>10.1 v_tenant_entitlements_full</h3>
|
||||
<pre><code>create or replace view public.v_tenant_entitlements_full as
|
||||
select
|
||||
a.tenant_id,
|
||||
f.key as feature_key,
|
||||
(pf.enabled = true) as allowed,
|
||||
pf.limits,
|
||||
a.plan_id,
|
||||
p.key as plan_key
|
||||
from public.v_tenant_active_subscription a
|
||||
join public.plan_features pf on pf.plan_id = a.plan_id
|
||||
join public.features f on f.id = pf.feature_id
|
||||
join public.plans p on p.id = a.plan_id;</code></pre>
|
||||
|
||||
<h3>10.2 v_tenant_entitlements_json</h3>
|
||||
<pre><code>create or replace view public.v_tenant_entitlements_json as
|
||||
select
|
||||
tenant_id,
|
||||
max(plan_key) as plan_key,
|
||||
jsonb_object_agg(
|
||||
feature_key,
|
||||
jsonb_build_object(
|
||||
'allowed', allowed,
|
||||
'limits', coalesce(limits, '{}'::jsonb)
|
||||
)
|
||||
order by feature_key
|
||||
) as entitlements
|
||||
from public.v_tenant_entitlements_full
|
||||
group by tenant_id;</code></pre>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Uso no front:</strong> uma única consulta retorna <code>plan_key</code> + mapa de entitlements com limits.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="11">
|
||||
<h2>11. Triggers de proteção (Guardrails)</h2>
|
||||
<p>Triggers confirmadas em <code>public.plans</code>:</p>
|
||||
<pre><code>trg_no_delete_core_plans
|
||||
trg_no_change_plan_target
|
||||
trg_no_change_core_plan_key</code></pre>
|
||||
|
||||
<h3>11.1 Funções (versões base)</h3>
|
||||
<pre><code>-- guard_no_delete_core_plans(): impede deletar planos core
|
||||
-- guard_no_change_core_plan_key(): impede alterar key dos planos core
|
||||
-- guard_no_change_plan_target(): impede alterar target de qualquer plano</code></pre>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Armadilha comum:</strong> tentar “corrigir plano core” via UPDATE direto. O trigger bloqueia e isso é desejável.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="12">
|
||||
<h2>12. Correção segura de plano core (bypass controlado)</h2>
|
||||
<p>
|
||||
Caso real desta sessão: <code>clinic_free</code> estava com <code>target</code> incorreto.
|
||||
O objetivo foi corrigir sem “desligar guardrails”.
|
||||
</p>
|
||||
|
||||
<h3>12.1 Patch do guardrail para bypass por transação</h3>
|
||||
<pre><code>create or replace function public.guard_no_change_plan_target()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
v_bypass text;
|
||||
begin
|
||||
v_bypass := current_setting('app.plan_migration_bypass', true);
|
||||
|
||||
if v_bypass = '1' then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
if new.target is distinct from old.target then
|
||||
raise exception 'Não é permitido alterar target do plano (%) de % para %.',
|
||||
old.key, old.target, new.target
|
||||
using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end
|
||||
$$;</code></pre>
|
||||
|
||||
<h3>12.2 Função administrativa (SECURITY DEFINER)</h3>
|
||||
<pre><code>create or replace function public.admin_fix_plan_target(
|
||||
p_plan_key text,
|
||||
p_new_target text
|
||||
) returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_plan_id uuid;
|
||||
begin
|
||||
if p_new_target not in ('clinic','therapist') then
|
||||
raise exception 'Target inválido: %', p_new_target using errcode='P0001';
|
||||
end if;
|
||||
|
||||
select id into v_plan_id
|
||||
from public.plans
|
||||
where key = p_plan_key
|
||||
for update;
|
||||
|
||||
if v_plan_id is null then
|
||||
raise exception 'Plano não encontrado: %', p_plan_key using errcode='P0001';
|
||||
end if;
|
||||
|
||||
if exists (select 1 from public.subscriptions s where s.plan_id = v_plan_id) then
|
||||
raise exception 'Plano % possui subscriptions. Migração bloqueada.', p_plan_key using errcode='P0001';
|
||||
end if;
|
||||
|
||||
perform set_config('app.plan_migration_bypass', '1', true);
|
||||
|
||||
update public.plans
|
||||
set target = p_new_target
|
||||
where id = v_plan_id;
|
||||
end
|
||||
$$;</code></pre>
|
||||
|
||||
<h3>12.3 Execução (caso real)</h3>
|
||||
<pre><code>select public.admin_fix_plan_target('clinic_free', 'clinic');</code></pre>
|
||||
|
||||
<div class="callout ok">
|
||||
<strong>Resultado:</strong> plano core corrigido, guardrail permanece ativo. Bypass vale apenas na transação.
|
||||
</div>
|
||||
|
||||
<h3>12.4 Hardening recomendado</h3>
|
||||
<pre><code>revoke execute on function public.admin_fix_plan_target(text, text) from public;
|
||||
-- depois conceder apenas ao role administrativo apropriado</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="section" id="13">
|
||||
<h2>13. Seeder idempotente: features + plan_features</h2>
|
||||
<p>
|
||||
O banco já continha features. O mapeamento MVP de plan_features foi validado e segue a ideia:
|
||||
PRO habilita tudo e limites “altos”; FREE habilita subset e limites menores.
|
||||
</p>
|
||||
<pre><code>-- padrão do seed (exemplo):
|
||||
-- insert into features(key, descricao, description) values (...)
|
||||
-- on conflict (key) do update set ...
|
||||
|
||||
-- insert into plan_features(plan_id, feature_id, enabled, limits) values (...)
|
||||
-- on conflict (plan_id, feature_id) do update set enabled=excluded.enabled, limits=excluded.limits;</code></pre>
|
||||
<div class="callout info">
|
||||
<strong>Dica operacional:</strong> manter seed idempotente evita “duplicação” e reduz bugs em ambientes de teste.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="14">
|
||||
<h2>14. Seeder idempotente: subscription de teste</h2>
|
||||
<p>Como as views dependem de uma subscription ativa, criamos uma assinatura manual de teste para um tenant real.</p>
|
||||
<pre><code>insert into public.subscriptions (
|
||||
tenant_id,
|
||||
plan_id,
|
||||
status,
|
||||
plan_key,
|
||||
"interval",
|
||||
current_period_start,
|
||||
current_period_end,
|
||||
provider,
|
||||
source
|
||||
)
|
||||
values (
|
||||
'<TENANT_UUID>',
|
||||
(select id from public.plans where key = 'clinic_free'),
|
||||
'active',
|
||||
'clinic_free',
|
||||
'month',
|
||||
now(),
|
||||
null,
|
||||
'manual',
|
||||
'manual'
|
||||
);</code></pre>
|
||||
|
||||
<div class="callout ok">
|
||||
<strong>Validação:</strong> após inserir, <code>v_tenant_active_subscription</code> e <code>v_tenant_entitlements_json</code> devem retornar dados.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="15">
|
||||
<h2>15. Front-end: padrões e telas</h2>
|
||||
<p><strong>Padrões adotados nesta sessão:</strong></p>
|
||||
<ul>
|
||||
<li>Em arquivos Vue: <strong>script</strong> → <strong>template</strong> → <strong>style</strong>.</li>
|
||||
<li>Busca com <code>FloatLabel</code> + <code>IconField</code> + <code>InputIcon</code>.</li>
|
||||
<li>Telas operacionais: DataTable com paginação, estados empty e UX “foco” via <code>?q=...</code>.</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Melhorias aplicadas:</strong> Cards para “foco”, botão voltar no topo, textos mais claros e layout mais estável.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="16">
|
||||
<h2>16. Troubleshooting (erros reais)</h2>
|
||||
|
||||
<h3>16.1 “Não é permitido alterar target do plano …”</h3>
|
||||
<p>Causa: trigger <code>guard_no_change_plan_target</code>. Solução: bypass controlado + função admin (seção 12).</p>
|
||||
|
||||
<h3>16.2 “Não é permitido alterar a key do plano padrão …”</h3>
|
||||
<p>Causa: trigger <code>guard_no_change_core_plan_key</code>. Solução: não renomear core; criar novo plano se necessário.</p>
|
||||
|
||||
<h3>16.3 Entitlements view vazia</h3>
|
||||
<p>Causa: ausência de subscription ativa em <code>v_tenant_active_subscription</code>. Solução: inserir subscription de teste (seção 14).</p>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Lembrete:</strong> quando algo “não retorna nada”, primeiro verifique as views-base antes de mexer no front.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="17">
|
||||
<h2>17. Checklist de validação</h2>
|
||||
<ul>
|
||||
<li><strong>Plans:</strong> core existe e está ativo; targets corretos.</li>
|
||||
<li><strong>Pricing:</strong> PRO tem preço vigente (active_to null); FREE pode ficar sem price.</li>
|
||||
<li><strong>Subscriptions:</strong> existe ao menos 1 assinatura ativa para testar.</li>
|
||||
<li><strong>Entitlements:</strong> <code>v_tenant_entitlements_json</code> retorna mapa com <code>allowed</code> + <code>limits</code>.</li>
|
||||
<li><strong>Guardrails:</strong> triggers ativas; correção de core somente via função admin.</li>
|
||||
<li><strong>Front:</strong> telas operacionais OK; foco via query; layout consistente.</li>
|
||||
</ul>
|
||||
<div class="callout ok">
|
||||
<strong>Meta:</strong> com este checklist, qualquer dev/operador consegue diagnosticar Billing em minutos.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<footer>
|
||||
<div><strong>Agência PSI — Documento Mestre Billing v2.0</strong></div>
|
||||
<div class="small">Gerado em 2026-03-01 10:43:18 UTC. Estrutura inspirada no padrão interno com sidebar + anchors.</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,206 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Agência PSI — Billing & Subscriptions v1.2</title>
|
||||
<style>
|
||||
body{
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
margin:0;
|
||||
padding:40px;
|
||||
background:#f6f8fc;
|
||||
color:#0f172a;
|
||||
}
|
||||
h1{font-size:28px;margin-bottom:8px;}
|
||||
h2{margin-top:40px;font-size:20px;}
|
||||
h3{margin-top:24px;font-size:16px;}
|
||||
p{line-height:1.6;color:#334155;}
|
||||
pre{
|
||||
background:#0f172a;
|
||||
color:#e2e8f0;
|
||||
padding:16px;
|
||||
border-radius:12px;
|
||||
overflow:auto;
|
||||
}
|
||||
code{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;}
|
||||
.section{margin-bottom:40px;}
|
||||
.badge{
|
||||
display:inline-block;
|
||||
padding:4px 10px;
|
||||
border-radius:999px;
|
||||
background:#e2e8f0;
|
||||
font-size:12px;
|
||||
margin-right:6px;
|
||||
}
|
||||
.rule{
|
||||
background:#e0f2fe;
|
||||
padding:14px;
|
||||
border-left:4px solid #0284c7;
|
||||
border-radius:10px;
|
||||
margin-top:12px;
|
||||
}
|
||||
.warn{
|
||||
background:#fef3c7;
|
||||
padding:14px;
|
||||
border-left:4px solid #d97706;
|
||||
border-radius:10px;
|
||||
margin-top:12px;
|
||||
}
|
||||
.danger{
|
||||
background:#fee2e2;
|
||||
padding:14px;
|
||||
border-left:4px solid #dc2626;
|
||||
border-radius:10px;
|
||||
margin-top:12px;
|
||||
}
|
||||
.ok{
|
||||
background:#dcfce7;
|
||||
padding:14px;
|
||||
border-left:4px solid #16a34a;
|
||||
border-radius:10px;
|
||||
margin-top:12px;
|
||||
}
|
||||
footer{
|
||||
margin-top:60px;
|
||||
font-size:12px;
|
||||
color:#64748b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Billing & Subscriptions — v1.2</h1>
|
||||
<p><strong>Agência PSI</strong> — Documento consolidado da sessão técnica sobre Subscriptions, Guardrails e Seeder.</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Escopo desta versão</h2>
|
||||
<p>Este documento consolida tudo o que foi tratado nesta sessão:</p>
|
||||
<ul>
|
||||
<li>Modelagem real da tabela <code>subscriptions</code></li>
|
||||
<li>Histórico via <code>subscription_events</code></li>
|
||||
<li>Triggers (guardrails) de proteção</li>
|
||||
<li>Views oficiais</li>
|
||||
<li>Seeder completo (planos + preços + metadata pública)</li>
|
||||
<li>Erros reais encontrados e solução aplicada</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Estrutura confirmada — subscriptions</h2>
|
||||
<pre><code>id uuid PK
|
||||
tenant_id uuid NULL
|
||||
user_id uuid NULL
|
||||
plan_id uuid NOT NULL
|
||||
plan_key text NULL
|
||||
interval text CHECK ('month','year')
|
||||
status text DEFAULT 'active'
|
||||
current_period_start timestamptz
|
||||
current_period_end timestamptz
|
||||
provider text DEFAULT 'manual'
|
||||
cancel_at_period_end boolean DEFAULT false
|
||||
created_at timestamptz DEFAULT now()
|
||||
updated_at timestamptz DEFAULT now()</code></pre>
|
||||
|
||||
<div class="rule">
|
||||
Assinatura de clínica exige <strong>tenant_id</strong>.
|
||||
Assinatura de terapeuta pode usar <strong>user_id</strong>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Guardrails (Proteções de Integridade)</h2>
|
||||
|
||||
<h3>3.1 Impedir deletar planos core</h3>
|
||||
<pre><code>create or replace function guard_no_delete_core_plans()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro') then
|
||||
raise exception 'Plano padrão (%) não pode ser removido.', old.key;
|
||||
end if;
|
||||
return old;
|
||||
end $$;</code></pre>
|
||||
|
||||
<h3>3.2 Impedir alterar target</h3>
|
||||
<pre><code>create or replace function guard_no_change_plan_target()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
if new.target is distinct from old.target then
|
||||
raise exception 'Não é permitido alterar target do plano.';
|
||||
end if;
|
||||
return new;
|
||||
end $$;</code></pre>
|
||||
|
||||
<h3>3.3 Impedir alterar key core</h3>
|
||||
<pre><code>create or replace function guard_no_change_core_plan_key()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro')
|
||||
and new.key is distinct from old.key then
|
||||
raise exception 'Não é permitido alterar a key do plano padrão.';
|
||||
end if;
|
||||
return new;
|
||||
end $$;</code></pre>
|
||||
|
||||
<div class="warn">
|
||||
Esses guardrails impediram alterações indevidas quando tentamos renomear planos core.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Views Oficiais</h2>
|
||||
<p><strong>v_public_pricing</strong> — Tela pública de preços.</p>
|
||||
<p><strong>v_tenant_active_subscription</strong> — Plano vigente do tenant.</p>
|
||||
<p><strong>v_subscription_health</strong> — Diagnóstico de inconsistências.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Seeder Oficial (MVP)</h2>
|
||||
|
||||
<pre><code>insert into plans (key,name,target,is_active)
|
||||
values
|
||||
('clinic_free','Clinic Free','clinic',true),
|
||||
('clinic_pro','Clinic Pro','clinic',true),
|
||||
('therapist_free','Therapist Free','therapist',true),
|
||||
('therapist_pro','Therapist Pro','therapist',true)
|
||||
on conflict (key) do update set
|
||||
name=excluded.name,
|
||||
target=excluded.target,
|
||||
is_active=excluded.is_active;</code></pre>
|
||||
|
||||
<div class="ok">
|
||||
Seeder é idempotente. Pode rodar múltiplas vezes sem duplicar.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Incidentes reais resolvidos</h2>
|
||||
|
||||
<h3>6.1 Pricing retornando null</h3>
|
||||
<p>Causa: não havia preço vigente (is_active=true e active_to is null).</p>
|
||||
|
||||
<h3>6.2 Erro ao alterar plano padrão</h3>
|
||||
<p>Causa: trigger guard_no_change_core_plan_key bloqueando alteração.</p>
|
||||
|
||||
<h3>6.3 Assinatura sem tenant_id</h3>
|
||||
<p>Causa: regra de negócio no banco impedindo clinic sem tenant.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Diretrizes finais</h2>
|
||||
<ul>
|
||||
<li>Plano nunca deve ser inferido do role.</li>
|
||||
<li>UI deve consumir apenas views oficiais.</li>
|
||||
<li>Plano core nunca deve ser renomeado.</li>
|
||||
<li>Preço sempre deve ter vigência ativa.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Agência PSI — Billing & Subscriptions v1.2<br>
|
||||
Documento gerado automaticamente.
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,308 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Arquitetura Técnica Completa — Billing & Assinaturas | Agência PSI</title>
|
||||
<style>
|
||||
body{font-family:Arial,Helvetica,sans-serif;margin:40px;line-height:1.65;color:#111}
|
||||
h1,h2,h3{margin-top:36px}
|
||||
code,pre{background:#f4f4f4;padding:12px;border-radius:8px;display:block;overflow:auto;font-size:13px}
|
||||
.section{margin-bottom:40px}
|
||||
.small{font-size:13px;color:#555}
|
||||
ul{margin-left:20px}
|
||||
.diagram{background:#fafafa;border:1px solid #ddd;padding:16px;border-radius:8px;font-family:monospace;font-size:13px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Arquitetura Técnica Completa — Billing & Assinaturas</h1>
|
||||
<p class="small">Projeto: Agência PSI • Documento estrutural definitivo do domínio de Billing.</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Visão Arquitetural Geral</h2>
|
||||
|
||||
<div class="diagram">
|
||||
USUÁRIO
|
||||
│
|
||||
▼
|
||||
subscription_intents (VIEW unificada)
|
||||
│
|
||||
▼ (RPC activate_subscription_from_intent)
|
||||
subscriptions
|
||||
│
|
||||
▼
|
||||
subscription_events (auditoria)
|
||||
│
|
||||
▼
|
||||
entitlements (derivados do plano)
|
||||
</div>
|
||||
|
||||
<p>Separação estrutural:</p>
|
||||
<ul>
|
||||
<li><strong>plans</strong> → catálogo</li>
|
||||
<li><strong>plan_prices</strong> → preço versionado</li>
|
||||
<li><strong>subscription_intents_*</strong> → intenção pré-pagamento</li>
|
||||
<li><strong>subscriptions</strong> → assinatura ativa</li>
|
||||
<li><strong>subscription_events</strong> → histórico</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Estados Oficiais da Assinatura</h2>
|
||||
<pre>
|
||||
pending
|
||||
active
|
||||
past_due
|
||||
suspended
|
||||
cancelled
|
||||
expired
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Índice de Integridade de Preços</h2>
|
||||
<pre>
|
||||
create unique index if not exists uq_plan_price_active
|
||||
on plan_prices (plan_id, interval, currency)
|
||||
where is_active = true and active_to is null;
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Função Completa — activate_subscription_from_intent</h2>
|
||||
<pre>
|
||||
CREATE OR REPLACE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid)
|
||||
RETURNS subscriptions
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
declare
|
||||
v_intent record;
|
||||
v_sub public.subscriptions;
|
||||
v_days int;
|
||||
v_user_id uuid;
|
||||
v_plan_id uuid;
|
||||
v_target text;
|
||||
begin
|
||||
|
||||
select * into v_intent
|
||||
from public.subscription_intents
|
||||
where id = p_intent_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'Intent não encontrada';
|
||||
end if;
|
||||
|
||||
if v_intent.status <> 'paid' then
|
||||
raise exception 'Intent precisa estar paid';
|
||||
end if;
|
||||
|
||||
select p.id, p.target
|
||||
into v_plan_id, v_target
|
||||
from public.plans p
|
||||
where p.key = v_intent.plan_key
|
||||
limit 1;
|
||||
|
||||
if v_plan_id is null then
|
||||
raise exception 'Plano não encontrado';
|
||||
end if;
|
||||
|
||||
v_target := lower(coalesce(v_target,''));
|
||||
|
||||
if v_target = 'clinic' and v_intent.tenant_id is null then
|
||||
raise exception 'Intent clinic exige tenant_id';
|
||||
end if;
|
||||
|
||||
if v_target = 'therapist' and v_intent.tenant_id is not null then
|
||||
raise exception 'Intent therapist não deve ter tenant_id';
|
||||
end if;
|
||||
|
||||
v_days := case when v_intent.interval = 'year' then 365 else 30 end;
|
||||
|
||||
v_user_id := coalesce(v_intent.created_by_user_id, v_intent.user_id);
|
||||
|
||||
if v_user_id is null then
|
||||
raise exception 'user_id obrigatório';
|
||||
end if;
|
||||
|
||||
if v_target = 'clinic' then
|
||||
update subscriptions
|
||||
set status = 'cancelled',
|
||||
cancelled_at = now()
|
||||
where tenant_id = v_intent.tenant_id
|
||||
and status = 'active';
|
||||
else
|
||||
update subscriptions
|
||||
set status = 'cancelled',
|
||||
cancelled_at = now()
|
||||
where user_id = v_user_id
|
||||
and tenant_id is null
|
||||
and status = 'active';
|
||||
end if;
|
||||
|
||||
insert into subscriptions (
|
||||
user_id,
|
||||
plan_id,
|
||||
status,
|
||||
current_period_start,
|
||||
current_period_end,
|
||||
tenant_id,
|
||||
plan_key,
|
||||
interval,
|
||||
provider,
|
||||
started_at,
|
||||
activated_at
|
||||
)
|
||||
values (
|
||||
v_user_id,
|
||||
v_plan_id,
|
||||
'active',
|
||||
now(),
|
||||
now() + make_interval(days => v_days),
|
||||
case when v_target='clinic' then v_intent.tenant_id else null end,
|
||||
v_intent.plan_key,
|
||||
v_intent.interval,
|
||||
'manual',
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
returning * into v_sub;
|
||||
|
||||
return v_sub;
|
||||
|
||||
end;
|
||||
$function$;
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Função Completa — transition_subscription (segura)</h2>
|
||||
<pre>
|
||||
create or replace function public.transition_subscription(
|
||||
p_subscription_id uuid,
|
||||
p_to_status text,
|
||||
p_reason text default null,
|
||||
p_metadata jsonb default null
|
||||
)
|
||||
returns subscriptions
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_sub subscriptions;
|
||||
v_uid uuid;
|
||||
v_allowed boolean := false;
|
||||
begin
|
||||
|
||||
v_uid := auth.uid();
|
||||
|
||||
select * into v_sub
|
||||
from subscriptions
|
||||
where id = p_subscription_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'Assinatura não encontrada';
|
||||
end if;
|
||||
|
||||
if is_saas_admin() then
|
||||
v_allowed := true;
|
||||
end if;
|
||||
|
||||
if not v_allowed
|
||||
and v_sub.tenant_id is null
|
||||
and v_sub.user_id = v_uid then
|
||||
v_allowed := true;
|
||||
end if;
|
||||
|
||||
if not v_allowed
|
||||
and v_sub.tenant_id is not null then
|
||||
|
||||
if exists (
|
||||
select 1 from tenant_members tm
|
||||
where tm.tenant_id = v_sub.tenant_id
|
||||
and tm.user_id = v_uid
|
||||
and tm.status = 'active'
|
||||
and tm.role = 'tenant_admin'
|
||||
) then
|
||||
v_allowed := true;
|
||||
end if;
|
||||
|
||||
end if;
|
||||
|
||||
if not v_allowed then
|
||||
raise exception 'Sem permissão';
|
||||
end if;
|
||||
|
||||
update subscriptions
|
||||
set status = p_to_status,
|
||||
updated_at = now()
|
||||
where id = p_subscription_id
|
||||
returning * into v_sub;
|
||||
|
||||
insert into subscription_events (
|
||||
subscription_id,
|
||||
owner_id,
|
||||
event_type,
|
||||
created_at,
|
||||
created_by,
|
||||
source,
|
||||
reason,
|
||||
metadata
|
||||
)
|
||||
values (
|
||||
v_sub.id,
|
||||
coalesce(v_sub.tenant_id, v_sub.user_id),
|
||||
'status_changed',
|
||||
now(),
|
||||
v_uid,
|
||||
'manual_transition',
|
||||
p_reason,
|
||||
p_metadata
|
||||
);
|
||||
|
||||
return v_sub;
|
||||
|
||||
end;
|
||||
$$;
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Máquina de Estados Recomendada</h2>
|
||||
|
||||
<div class="diagram">
|
||||
pending → active → past_due → suspended → cancelled
|
||||
↓
|
||||
expired
|
||||
</div>
|
||||
|
||||
<p>Recomendação futura: validar allowed_transitions em tabela dedicada.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Checklist de Validação Estrutural</h2>
|
||||
<ul>
|
||||
<li>Intent paga gera subscription ativa</li>
|
||||
<li>subscription_id vinculado corretamente</li>
|
||||
<li>Cancelamento gera evento</li>
|
||||
<li>Reativação preserva histórico</li>
|
||||
<li>Tenant isolation validado</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>8. Roadmap Estrutural Futuro</h2>
|
||||
<ul>
|
||||
<li>State machine formal com allowed_transitions</li>
|
||||
<li>Automação de expiração por cron</li>
|
||||
<li>Integração Stripe mantendo arquitetura</li>
|
||||
<li>Health monitor automatizado</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
<p class="small">Documento técnico estrutural consolidado após implementação real validada.</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,231 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Agência PSI — Billing (Arquitetura Oficial v1.1)</title>
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--bg0:#f6f8fc;
|
||||
--bg1:#eef2f8;
|
||||
--panel:rgba(255,255,255,.85);
|
||||
--border:rgba(15,23,42,.10);
|
||||
--text:rgba(15,23,42,.92);
|
||||
--muted:rgba(15,23,42,.70);
|
||||
--accent:#2563eb;
|
||||
--ok:#047857;
|
||||
--warn:#b45309;
|
||||
--danger:#b91c1c;
|
||||
--radius:18px;
|
||||
--shadow:0 18px 60px rgba(2,6,23,.10);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0;
|
||||
font-family:var(--sans);
|
||||
background:linear-gradient(180deg,var(--bg0),var(--bg1));
|
||||
color:var(--text);
|
||||
}
|
||||
.layout{
|
||||
max-width:1100px;
|
||||
margin:0 auto;
|
||||
padding:40px 20px 80px;
|
||||
}
|
||||
header{
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel);
|
||||
border-radius:var(--radius);
|
||||
padding:28px;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
h1{margin:0 0 12px;font-size:30px}
|
||||
h2{margin-top:40px;font-size:20px}
|
||||
p{color:var(--muted);line-height:1.6}
|
||||
.section{
|
||||
margin-top:30px;
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel);
|
||||
padding:24px;
|
||||
border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
.rule{
|
||||
border-left:4px solid var(--accent);
|
||||
background:rgba(37,99,235,.08);
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
margin:18px 0;
|
||||
}
|
||||
.ok{
|
||||
border-left:4px solid var(--ok);
|
||||
background:rgba(4,120,87,.08);
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
margin:18px 0;
|
||||
}
|
||||
.warn{
|
||||
border-left:4px solid var(--warn);
|
||||
background:rgba(180,83,9,.10);
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
margin:18px 0;
|
||||
}
|
||||
.danger{
|
||||
border-left:4px solid var(--danger);
|
||||
background:rgba(185,28,28,.08);
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
margin:18px 0;
|
||||
}
|
||||
code,pre{font-family:var(--mono);font-size:13px}
|
||||
pre{
|
||||
background:rgba(2,6,23,.05);
|
||||
padding:14px;
|
||||
border-radius:12px;
|
||||
overflow:auto;
|
||||
}
|
||||
table{
|
||||
width:100%;
|
||||
border-collapse:collapse;
|
||||
margin-top:16px;
|
||||
}
|
||||
th,td{
|
||||
border:1px solid var(--border);
|
||||
padding:10px;
|
||||
font-size:13px;
|
||||
}
|
||||
th{background:rgba(15,23,42,.04)}
|
||||
footer{
|
||||
text-align:center;
|
||||
margin-top:50px;
|
||||
font-size:12px;
|
||||
color:var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="layout">
|
||||
|
||||
<header>
|
||||
<h1>Billing — Arquitetura Oficial v1.1</h1>
|
||||
<p>Versão 1.1 inclui procedimento formal de migração controlada para planos core, mantendo guardrails ativos e auditáveis.</p>
|
||||
</header>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Fundamentos do Domínio</h2>
|
||||
<p>Billing define recursos e limites do produto. Não é camada de UI. É camada estrutural.</p>
|
||||
<div class="rule"><strong>Princípio:</strong> Role (RBAC) ≠ Plano (Billing). Plano dirige features e limites; role dirige acesso.</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Planos Core (MVP)</h2>
|
||||
<ul>
|
||||
<li>clinic_free</li>
|
||||
<li>clinic_pro</li>
|
||||
<li>therapist_free</li>
|
||||
<li>therapist_pro</li>
|
||||
</ul>
|
||||
<div class="ok"><strong>Política:</strong> Planos core são estruturalmente protegidos por triggers.</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Governança de Guardrails</h2>
|
||||
<ul>
|
||||
<li>Impedem alterar key de plano core</li>
|
||||
<li>Impedem alterar target de plano core</li>
|
||||
<li>Impedem deletar plano com subscription ativa</li>
|
||||
</ul>
|
||||
<div class="danger"><strong>Proibido:</strong> desabilitar triggers diretamente em produção.</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Procedimento Oficial de Correção de Plano Core</h2>
|
||||
<p>Correções estruturais devem ocorrer via função administrativa controlada.</p>
|
||||
|
||||
<pre>
|
||||
create or replace function admin_fix_plan_target(
|
||||
p_plan_key text,
|
||||
p_new_target text
|
||||
) returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_plan_id uuid;
|
||||
begin
|
||||
select id into v_plan_id
|
||||
from plans
|
||||
where key = p_plan_key
|
||||
for update;
|
||||
|
||||
if v_plan_id is null then
|
||||
raise exception 'Plano não encontrado.';
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1 from subscriptions where plan_id = v_plan_id
|
||||
) then
|
||||
raise exception 'Plano possui subscriptions ativas.';
|
||||
end if;
|
||||
|
||||
update plans
|
||||
set target = p_new_target
|
||||
where id = v_plan_id;
|
||||
|
||||
end;
|
||||
$$;
|
||||
</pre>
|
||||
|
||||
<div class="warn">
|
||||
Esta função deve ser executada apenas por role administrativa e registrada em log de auditoria.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Entitlements (Contrato Oficial)</h2>
|
||||
<p>Entitlements são derivados exclusivamente de <code>plan_features</code>.</p>
|
||||
<pre>
|
||||
plan_features (
|
||||
plan_id uuid,
|
||||
feature_id uuid,
|
||||
enabled boolean,
|
||||
limits jsonb
|
||||
)
|
||||
</pre>
|
||||
<div class="rule">
|
||||
Formato oficial de limits:
|
||||
{"max": 30}
|
||||
{"per_month": 40}
|
||||
{"max_users": 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>6. Preço Vigente</h2>
|
||||
<pre>
|
||||
create unique index uq_plan_price_active
|
||||
on plan_prices (plan_id, interval, currency)
|
||||
where is_active = true and active_to is null;
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>7. Onboarding</h2>
|
||||
<ul>
|
||||
<li>Tenant clinic → clinic_free</li>
|
||||
<li>Tenant therapist → therapist_free</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Agência PSI — Billing Arquitetura Oficial v1.1
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
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>
|
||||
@@ -0,0 +1,612 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Relatório Técnico — Sessão de Correção: Subscription Health & Entitlements (2026-03-01) | Agência PSI</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg0:#f6f8fc;
|
||||
--bg1:#eef2f8;
|
||||
--panel:rgba(255,255,255,.78);
|
||||
--panel2:rgba(255,255,255,.92);
|
||||
--border:rgba(15,23,42,.10);
|
||||
--text:rgba(15,23,42,.92);
|
||||
--muted:rgba(15,23,42,.70);
|
||||
--muted2:rgba(15,23,42,.56);
|
||||
--accent:#2563eb;
|
||||
--accent2:#4f46e5;
|
||||
--warn:#b45309;
|
||||
--danger:#b91c1c;
|
||||
--ok:#047857;
|
||||
--shadow: 0 18px 60px rgba(2,6,23,.10);
|
||||
--radius: 16px;
|
||||
--radius2: 22px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{
|
||||
margin:0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
background: radial-gradient(1200px 600px at 10% 0%, rgba(79,70,229,.10), transparent 55%),
|
||||
radial-gradient(1100px 500px at 90% 10%, rgba(37,99,235,.10), transparent 55%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
color:var(--text);
|
||||
}
|
||||
a{color:inherit; text-decoration:none;}
|
||||
a:hover{text-decoration:underline;}
|
||||
.layout{
|
||||
display:grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 20px;
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 42px;
|
||||
}
|
||||
header{
|
||||
grid-column: 1 / -1;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.92), rgba(255,255,255,.72));
|
||||
border-radius: var(--radius2);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.kicker{
|
||||
font-size:12px;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
color:var(--muted2);
|
||||
margin:0 0 8px;
|
||||
}
|
||||
h1{
|
||||
margin:0 0 8px;
|
||||
font-size:30px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
.subtitle{
|
||||
margin:0;
|
||||
color:var(--muted);
|
||||
max-width:980px;
|
||||
line-height:1.55;
|
||||
font-size:14px;
|
||||
}
|
||||
.meta-row{
|
||||
margin-top:12px;
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
}
|
||||
.pill{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
padding:8px 12px;
|
||||
border-radius:999px;
|
||||
border:1px solid var(--border);
|
||||
background:rgba(255,255,255,.72);
|
||||
color:var(--muted);
|
||||
font-size:12px;
|
||||
}
|
||||
.dot{
|
||||
width:8px;height:8px;border-radius:50%;
|
||||
background:var(--accent);
|
||||
box-shadow:0 0 0 4px rgba(37,99,235,.12);
|
||||
}
|
||||
aside{
|
||||
position:sticky;
|
||||
top:18px;
|
||||
align-self:start;
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel2);
|
||||
border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.toc-head{
|
||||
padding:14px;
|
||||
border-bottom:1px solid var(--border);
|
||||
background:rgba(15,23,42,.02);
|
||||
}
|
||||
.toc-title{ margin:0 0 6px; font-weight:700; font-size:14px; }
|
||||
.toc-sub{ margin:0; color:var(--muted); font-size:12px; line-height:1.45; }
|
||||
nav{ padding: 10px 6px 14px; }
|
||||
nav a{
|
||||
display:block;
|
||||
padding:10px 12px;
|
||||
margin:4px 6px;
|
||||
border-radius:12px;
|
||||
color:var(--muted);
|
||||
font-size:13px;
|
||||
border:1px solid transparent;
|
||||
}
|
||||
nav a:hover{
|
||||
background:rgba(37,99,235,.06);
|
||||
border-color:rgba(37,99,235,.12);
|
||||
color:var(--text);
|
||||
text-decoration:none;
|
||||
}
|
||||
.nav-sec{
|
||||
margin:10px 12px 6px;
|
||||
color:var(--muted2);
|
||||
font-size:11px;
|
||||
letter-spacing:.08em;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
main{
|
||||
border:1px solid var(--border);
|
||||
background:var(--panel);
|
||||
border-radius:var(--radius2);
|
||||
box-shadow:var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.content{ padding: 18px 18px 22px; }
|
||||
.section{
|
||||
padding: 18px;
|
||||
border:1px solid var(--border);
|
||||
border-radius: var(--radius2);
|
||||
background: rgba(255,255,255,.80);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section h2{
|
||||
margin:0 0 10px;
|
||||
font-size:18px;
|
||||
letter-spacing:-0.01em;
|
||||
}
|
||||
.section h3{
|
||||
margin:14px 0 8px;
|
||||
font-size:14px;
|
||||
}
|
||||
.section p, .section li{
|
||||
color:var(--muted);
|
||||
line-height:1.65;
|
||||
font-size:13.5px;
|
||||
}
|
||||
.grid{
|
||||
display:grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 980px){
|
||||
.layout{ grid-template-columns: 1fr; }
|
||||
aside{ position:relative; top:auto; }
|
||||
.grid{ grid-template-columns: 1fr; }
|
||||
}
|
||||
.callout{
|
||||
border-radius: 16px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
border:1px solid var(--border);
|
||||
background: rgba(15,23,42,.02);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.callout strong{color:var(--text);}
|
||||
.callout.ok{ border-left: 4px solid var(--ok); background: rgba(4,120,87,.06); }
|
||||
.callout.warn{ border-left: 4px solid var(--warn); background: rgba(180,83,9,.08); }
|
||||
.callout.danger{ border-left: 4px solid var(--danger); background: rgba(185,28,28,.08); }
|
||||
.callout.info{ border-left: 4px solid var(--accent); background: rgba(37,99,235,.08); }
|
||||
pre{
|
||||
margin: 10px 0 0;
|
||||
padding: 14px;
|
||||
background: #0b1220;
|
||||
color:#e2e8f0;
|
||||
border-radius: 16px;
|
||||
overflow:auto;
|
||||
border: 1px solid rgba(226,232,240,.08);
|
||||
}
|
||||
code{ font-family: var(--mono); font-size:12.5px; }
|
||||
.kbd{
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255,255,255,.65);
|
||||
color: var(--text);
|
||||
}
|
||||
.hr{
|
||||
height:1px;
|
||||
background: var(--border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
footer{
|
||||
margin-top: 12px;
|
||||
padding: 14px 18px 18px;
|
||||
color: var(--muted2);
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: rgba(255,255,255,.70);
|
||||
}
|
||||
.small{ font-size:12px; color:var(--muted2); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<header>
|
||||
<p class="kicker">Relatório Técnico • Billing Health • Agência PSI</p>
|
||||
<h1>Subscription Health & Entitlements — Sessão de Correção (2026-03-01)</h1>
|
||||
<p class="subtitle">Este documento registra, de forma minuciosa e operacional, a sessão de diagnóstico e correção dos problemas
|
||||
na página <strong>Saúde das Assinaturas</strong> (Subscription Health) e no pipeline de <strong>Entitlements</strong>.
|
||||
O objetivo é permitir que qualquer programador entenda o incidente, replique o diagnóstico e aplique correções
|
||||
com segurança, mesmo sem ter acompanhado a conversa original.</p>
|
||||
<div class="meta-row">
|
||||
<span class="pill"><span class="dot"></span><strong>Estado:</strong> resolvido e hardenizado</span>
|
||||
<span class="pill"><strong>Atualizado:</strong> 2026-03-01 11:46:44 UTC</span>
|
||||
<span class="pill"><strong>Stack:</strong> Supabase + Postgres + Vue/PrimeVue</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside>
|
||||
<div class="toc-head">
|
||||
<div class="toc-title">Sumário</div>
|
||||
<p class="toc-sub">Leitura rápida com passos reproduzíveis (SQL + patches + checklist).</p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="nav-sec">Visão geral</div>
|
||||
<a href="#01">Resumo executivo</a>
|
||||
<a href="#02">Escopo e componentes</a>
|
||||
<a href="#03">Sintomas e evidências</a>
|
||||
<div class="nav-sec">Diagnóstico</div>
|
||||
<a href="#04">Causa raiz</a>
|
||||
<a href="#05">SQLs de diagnóstico</a>
|
||||
<div class="nav-sec">Correções</div>
|
||||
<a href="#06">Patches aplicados</a>
|
||||
<a href="#07">Hardening</a>
|
||||
<div class="nav-sec">Validação</div>
|
||||
<a href="#08">Checklist pós-correção</a>
|
||||
<div class="nav-sec">Contexto</div>
|
||||
<a href="#09">Notas de front-end</a>
|
||||
<a href="#10">Linha do tempo</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="content">
|
||||
|
||||
<section class="section" id="01">
|
||||
<h2>1. Resumo executivo</h2>
|
||||
|
||||
<p>
|
||||
<strong>Sintoma principal:</strong> a tela <em>Saúde das Assinaturas</em> exibia divergências e a coluna <strong>Owner</strong> aparecia vazia
|
||||
(linhas com <code>owner_id = NULL</code>). Além disso, os botões <strong>Fix</strong> e <strong>Fix All</strong> falhavam.
|
||||
</p>
|
||||
<div class="callout danger">
|
||||
<strong>Impacto:</strong> a ferramenta de diagnóstico do Billing ficou pouco confiável e os reparos automáticos não executavam, impedindo
|
||||
correções rápidas após mudanças de plano/feature.
|
||||
</div>
|
||||
<div class="callout ok">
|
||||
<strong>Resultado final:</strong> view de entitlements corrigida (filtra <code>subscriptions.status = 'active'</code> e exclui NULL),
|
||||
funções RPC alinhadas ao schema atual (<code>subscriptions.user_id</code>), dados inválidos removidos e constraints/índices adicionados
|
||||
para impedir regressões.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="02">
|
||||
<h2>2. Escopo e componentes envolvidos</h2>
|
||||
|
||||
<div class="grid">
|
||||
<div class="callout">
|
||||
<strong>View de saúde</strong>
|
||||
<p><code>public.v_subscription_feature_mismatch</code> — compara o esperado (plan_features do plano ativo) com o atual (entitlements).</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>Entitlements agregados</strong>
|
||||
<p><code>public.owner_feature_entitlements</code> — <em>VIEW</em> agregada (sources + limits_list), derivada de <code>subscriptions</code> e <code>tenant_modules</code>.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>Rotinas de reparo</strong>
|
||||
<p><code>public.rebuild_owner_entitlements(uuid)</code> e <code>public.fix_all_subscription_mismatches()</code>.</p>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>Tabelas de configuração</strong>
|
||||
<p><code>plans</code>, <code>features</code>, <code>plan_features</code>, <code>module_features</code>, <code>tenant_modules</code>, <code>subscriptions</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="03">
|
||||
<h2>3. Sintomas observados e evidências</h2>
|
||||
|
||||
<p>Foram observados os seguintes indícios no banco:</p>
|
||||
<ul>
|
||||
<li>View <code>v_subscription_feature_mismatch</code> retornando <code>owner_id = NULL</code> tanto em <em>missing</em> quanto em <em>unexpected</em>.</li>
|
||||
<li>Contagem estável em 4/4 divergências, mesmo após tentativa de reparo.</li>
|
||||
<li>Existência de uma <code>subscription</code> com <code>status='active'</code> e <code>user_id = NULL</code> (dado inválido).</li>
|
||||
<li>Falha de execução do FixAll com erro de coluna inexistente: <code>subscriptions.owner_id</code> (schema drift).</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Nota de leitura:</strong> ao ver <code>owner_id = NULL</code> em divergências, trate como anomalia de dados ou view/joins permissivos.
|
||||
Na prática, “owner nulo” não é um caso de negócio — é um caso de <em>integridade</em>.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="04">
|
||||
<h2>4. Diagnóstico e causa raiz</h2>
|
||||
|
||||
<h3>4.1 Causa raiz #1 — Schema drift nas funções RPC</h3>
|
||||
<p>
|
||||
As funções de reparo estavam escritas para um schema anterior, usando <code>subscriptions.owner_id</code>. No schema atual, o owner do contexto
|
||||
“terapeuta” é <code>subscriptions.user_id</code>. Isso quebrou:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Fix owner</strong>: falha ao buscar o plano ativo do owner.</li>
|
||||
<li><strong>Fix all</strong>: falha ao iterar owners e chamar o rebuild.</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout danger">
|
||||
<strong>Erro observado:</strong> <code>column "owner_id" does not exist</code> (hint citando <code>subscriptions.user_id</code>).
|
||||
</div>
|
||||
|
||||
<h3>4.2 Causa raiz #2 — View de entitlements agregados não filtrava status</h3>
|
||||
<p>
|
||||
A view <code>owner_feature_entitlements</code> agregava a fonte “plan” sem filtrar <code>subscriptions.status</code>, permitindo que uma subscription
|
||||
<em>inactive</em> com <code>user_id NULL</code> continuasse “vazando” entitlements com <code>owner_id NULL</code> para o sistema.
|
||||
</p>
|
||||
|
||||
<h3>4.3 Causa raiz #3 — Dado inválido</h3>
|
||||
<p>
|
||||
Foi identificado um registro em <code>subscriptions</code> com <code>user_id NULL</code>. Mesmo após torná-lo <em>inactive</em>, ele continuava contaminando
|
||||
a view (por ausência do filtro de status).
|
||||
</p>
|
||||
|
||||
</section>
|
||||
<section class="section" id="05">
|
||||
<h2>5. SQLs usados no diagnóstico (playbook)</h2>
|
||||
|
||||
<p>Use este bloco para reproduzir o diagnóstico com segurança.</p>
|
||||
|
||||
<h3>5.1 Ver divergências e amostras</h3>
|
||||
<pre><code>select mismatch_type, count(*) as qtd
|
||||
from public.v_subscription_feature_mismatch
|
||||
group by 1
|
||||
order by 2 desc;
|
||||
|
||||
select owner_id, feature_key, mismatch_type
|
||||
from public.v_subscription_feature_mismatch
|
||||
order by owner_id nulls first, feature_key
|
||||
limit 50;</code></pre>
|
||||
|
||||
<h3>5.2 Encontrar subscriptions inválidas (user_id nulo)</h3>
|
||||
<pre><code>select id, user_id, plan_id, status, created_at
|
||||
from public.subscriptions
|
||||
where user_id is null
|
||||
order by created_at desc;</code></pre>
|
||||
|
||||
<h3>5.3 Entender a origem dos entitlements agregados</h3>
|
||||
<pre><code>select pg_get_viewdef('public.owner_feature_entitlements'::regclass, true) as view_sql;</code></pre>
|
||||
|
||||
<h3>5.4 Verificar tenant_modules inválidos (owner_id nulo)</h3>
|
||||
<pre><code>select count(*) as qtd
|
||||
from public.tenant_modules
|
||||
where status = 'active' and owner_id is null;</code></pre>
|
||||
|
||||
</section>
|
||||
<section class="section" id="06">
|
||||
<h2>6. Correções aplicadas no banco (patches)</h2>
|
||||
|
||||
<h3>6.1 Patch: rebuild_owner_entitlements (owner = subscriptions.user_id)</h3>
|
||||
<p>
|
||||
Ajuste para buscar o plano ativo por <code>subscriptions.user_id</code> e reconstruir entitlements com base em <code>plan_features</code>.
|
||||
</p>
|
||||
<pre><code>create or replace function public.rebuild_owner_entitlements(p_owner_id uuid)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
v_plan_id uuid;
|
||||
begin
|
||||
select s.plan_id
|
||||
into v_plan_id
|
||||
from public.subscriptions s
|
||||
where s.user_id = p_owner_id
|
||||
and s.status = 'active'
|
||||
order by s.created_at desc
|
||||
limit 1;
|
||||
|
||||
delete from public.owner_feature_entitlements e
|
||||
where e.owner_id = p_owner_id;
|
||||
|
||||
if v_plan_id is null then
|
||||
return;
|
||||
end if;
|
||||
|
||||
insert into public.owner_feature_entitlements (owner_id, feature_key, sources, limits_list)
|
||||
select
|
||||
p_owner_id,
|
||||
f.key,
|
||||
array['plan'::text],
|
||||
'{}'::jsonb
|
||||
from public.plan_features pf
|
||||
join public.features f on f.id = pf.feature_id
|
||||
where pf.plan_id = v_plan_id;
|
||||
end;
|
||||
$$;</code></pre>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Importante:</strong> se <code>owner_feature_entitlements</code> for uma <em>VIEW</em> (como no ambiente desta sessão),
|
||||
o <code>DELETE/INSERT</code> acima deve ser direcionado à <em>tabela base</em> real de entitlements, se existir.
|
||||
Nesta sessão, a correção definitiva foi feita ajustando a view agregadora e limpando o dado inválido.
|
||||
</div>
|
||||
|
||||
<h3>6.2 Patch: fix_all_subscription_mismatches (itera subscriptions.user_id)</h3>
|
||||
<pre><code>create or replace function public.fix_all_subscription_mismatches()
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
r record;
|
||||
begin
|
||||
for r in
|
||||
select distinct s.user_id as owner_id
|
||||
from public.subscriptions s
|
||||
where s.status = 'active'
|
||||
and s.user_id is not null
|
||||
loop
|
||||
perform public.rebuild_owner_entitlements(r.owner_id);
|
||||
end loop;
|
||||
end;
|
||||
$$;</code></pre>
|
||||
|
||||
<h3>6.3 Patch: owner_feature_entitlements (filtra status e NULLs)</h3>
|
||||
<pre><code>create or replace view public.owner_feature_entitlements as
|
||||
with base as (
|
||||
select
|
||||
s.user_id as owner_id,
|
||||
f.key as feature_key,
|
||||
pf.limits,
|
||||
'plan'::text as source
|
||||
from public.subscriptions s
|
||||
join public.plan_features pf
|
||||
on pf.plan_id = s.plan_id
|
||||
and pf.enabled = true
|
||||
join public.features f
|
||||
on f.id = pf.feature_id
|
||||
where s.status = 'active'
|
||||
and s.user_id is not null
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
tm.owner_id,
|
||||
f.key as feature_key,
|
||||
mf.limits,
|
||||
'module'::text as source
|
||||
from public.tenant_modules tm
|
||||
join public.modules m
|
||||
on m.id = tm.module_id
|
||||
and m.is_active = true
|
||||
join public.module_features mf
|
||||
on mf.module_id = m.id
|
||||
and mf.enabled = true
|
||||
join public.features f
|
||||
on f.id = mf.feature_id
|
||||
where tm.status = 'active'
|
||||
and tm.owner_id is not null
|
||||
)
|
||||
select
|
||||
owner_id,
|
||||
feature_key,
|
||||
array_agg(distinct source) as sources,
|
||||
jsonb_agg(limits) filter (where limits is not null) as limits_list
|
||||
from base
|
||||
group by owner_id, feature_key;</code></pre>
|
||||
|
||||
<h3>6.4 Limpeza do dado inválido (subscription com user_id NULL)</h3>
|
||||
<pre><code>-- se for lixo de seed/teste (recomendado remover):
|
||||
delete from public.subscriptions
|
||||
where user_id is null;</code></pre>
|
||||
|
||||
</section>
|
||||
<section class="section" id="07">
|
||||
<h2>7. Hardening (constraints e índices recomendados)</h2>
|
||||
|
||||
<p>Após corrigir dados e views, aplique hardening para impedir regressões.</p>
|
||||
|
||||
<h3>7.1 subscriptions.user_id NOT NULL</h3>
|
||||
<pre><code>alter table public.subscriptions
|
||||
alter column user_id set not null;</code></pre>
|
||||
|
||||
<h3>7.2 Uma assinatura ativa por usuário</h3>
|
||||
<pre><code>create unique index if not exists subscriptions_one_active_per_user
|
||||
on public.subscriptions (user_id)
|
||||
where status = 'active';</code></pre>
|
||||
|
||||
<h3>7.3 Índice de performance para consultas por owner/status</h3>
|
||||
<pre><code>create index if not exists subscriptions_user_status_idx
|
||||
on public.subscriptions (user_id, status, created_at desc);</code></pre>
|
||||
|
||||
<h3>7.4 tenant_modules.owner_id NOT NULL (decisão tomada nesta sessão)</h3>
|
||||
<pre><code>alter table public.tenant_modules
|
||||
alter column owner_id set not null;</code></pre>
|
||||
|
||||
<h3>7.5 Uniqueness e performance em plan_features / module_features</h3>
|
||||
<pre><code>create unique index if not exists plan_features_plan_feature_ux
|
||||
on public.plan_features (plan_id, feature_id);
|
||||
|
||||
create index if not exists plan_features_plan_enabled_idx
|
||||
on public.plan_features (plan_id, enabled);
|
||||
|
||||
create unique index if not exists module_features_module_feature_ux
|
||||
on public.module_features (module_id, feature_id);</code></pre>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Regra prática:</strong> dados inválidos (NULL em owner) devem ser bloqueados na borda (constraints), não “corrigidos” no front.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="08">
|
||||
<h2>8. Verificação pós-correção (checklist)</h2>
|
||||
|
||||
<h3>8.1 Saúde deve zerar</h3>
|
||||
<pre><code>select mismatch_type, count(*) as qtd
|
||||
from public.v_subscription_feature_mismatch
|
||||
group by 1
|
||||
order by 2 desc;</code></pre>
|
||||
|
||||
<h3>8.2 Não pode haver owner nulo em subscriptions / tenant_modules ativos</h3>
|
||||
<pre><code>select count(*) as subs_user_null
|
||||
from public.subscriptions
|
||||
where user_id is null;
|
||||
|
||||
select count(*) as tenant_modules_active_owner_null
|
||||
from public.tenant_modules
|
||||
where status='active' and owner_id is null;</code></pre>
|
||||
|
||||
<h3>8.3 Entitlements agregados não devem conter owner null</h3>
|
||||
<pre><code>select owner_id, feature_key
|
||||
from public.owner_feature_entitlements
|
||||
where owner_id is null
|
||||
limit 20;</code></pre>
|
||||
|
||||
<div class="callout ok">
|
||||
<strong>OK final:</strong> todas as queries acima retornam 0 linhas (ou contagens zero).
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="09">
|
||||
<h2>9. Notas de implementação no front-end (contexto)</h2>
|
||||
|
||||
<p>Durante a sessão, a UI foi ajustada para:</p>
|
||||
<ul>
|
||||
<li>Traduzir telas para PT-BR, melhorar títulos, descrições e mensagens.</li>
|
||||
<li>Padronizar inputs com <code>FloatLabel</code> + <code>IconField</code> + <code>InputIcon</code>.</li>
|
||||
<li>Adicionar confirmações e “alterações pendentes” em ações em massa (plan_features), evitando salvar por clique acidental.</li>
|
||||
<li>Garantir que ações de correção (Fix/FixAll) reflitam erros reais (RPC quebrada vs dados inválidos).</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout warn">
|
||||
<strong>Regra operacional:</strong> se a coluna Owner aparecer vazia, corrija no banco primeiro (dados/view),
|
||||
antes de mexer no front.
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="section" id="10">
|
||||
<h2>10. Linha do tempo da sessão (resumo)</h2>
|
||||
|
||||
<ul>
|
||||
<li><strong>Detecção:</strong> tela “Saúde das Assinaturas” exibindo Owner vazio e divergências.</li>
|
||||
<li><strong>Inspeção:</strong> <code>v_subscription_feature_mismatch</code> mostrava <code>owner_id NULL</code> em missing/unexpected.</li>
|
||||
<li><strong>Erro crítico:</strong> FixAll falhava com <code>subscriptions.owner_id</code> inexistente.</li>
|
||||
<li><strong>Correção #1:</strong> alinhar RPCs ao schema atual (<code>subscriptions.user_id</code>).</li>
|
||||
<li><strong>Correção #2:</strong> identificar que <code>owner_feature_entitlements</code> é VIEW e filtrar <code>status='active'</code>.</li>
|
||||
<li><strong>Correção #3:</strong> remover subscription inválida com <code>user_id NULL</code>.</li>
|
||||
<li><strong>Hardening:</strong> constraints e índices para prevenir regressões.</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout info">
|
||||
<strong>Atualizado:</strong> 2026-03-01 11:46:44 UTC
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
<div><strong>Agência PSI — Relatório Técnico (Billing Health)</strong></div>
|
||||
<div class="small">Documento operacional inspirado no “Documento Mestre Billing v2.0”. Atualizado em 2026-03-01 11:46:44 UTC.</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
414
Nova-Dev-Doc/supervisor_fase1.sql
Normal file
414
Nova-Dev-Doc/supervisor_fase1.sql
Normal file
@@ -0,0 +1,414 @@
|
||||
-- ============================================================
|
||||
-- SUPERVISOR — Fase 1
|
||||
-- Aplicar no Supabase SQL Editor (em ordem)
|
||||
-- ============================================================
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 1. tenants.kind → adiciona 'supervisor'
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.tenants
|
||||
DROP CONSTRAINT IF EXISTS tenants_kind_check;
|
||||
|
||||
ALTER TABLE public.tenants
|
||||
ADD CONSTRAINT tenants_kind_check
|
||||
CHECK (kind = ANY (ARRAY[
|
||||
'therapist',
|
||||
'clinic_coworking',
|
||||
'clinic_reception',
|
||||
'clinic_full',
|
||||
'clinic',
|
||||
'saas',
|
||||
'supervisor' -- ← novo
|
||||
]));
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 2. plans.target → adiciona 'supervisor'
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.plans
|
||||
DROP CONSTRAINT IF EXISTS plans_target_check;
|
||||
|
||||
ALTER TABLE public.plans
|
||||
ADD CONSTRAINT plans_target_check
|
||||
CHECK (target = ANY (ARRAY[
|
||||
'patient',
|
||||
'therapist',
|
||||
'clinic',
|
||||
'supervisor' -- ← novo
|
||||
]));
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 3. plans.max_supervisees — limite de supervisionados
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.plans
|
||||
ADD COLUMN IF NOT EXISTS max_supervisees integer DEFAULT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.plans.max_supervisees IS
|
||||
'Limite de terapeutas que podem ser supervisionados. Apenas para planos target=supervisor. NULL = sem limite.';
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 4. Planos supervisor_free e supervisor_pro
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.plans (key, name, description, target, is_active, max_supervisees)
|
||||
VALUES
|
||||
(
|
||||
'supervisor_free',
|
||||
'Supervisor Free',
|
||||
'Plano gratuito de supervisão. Até 3 terapeutas supervisionados.',
|
||||
'supervisor',
|
||||
true,
|
||||
3
|
||||
),
|
||||
(
|
||||
'supervisor_pro',
|
||||
'Supervisor PRO',
|
||||
'Plano profissional de supervisão. Até 20 terapeutas supervisionados.',
|
||||
'supervisor',
|
||||
true,
|
||||
20
|
||||
)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
target = EXCLUDED.target,
|
||||
is_active = EXCLUDED.is_active,
|
||||
max_supervisees = EXCLUDED.max_supervisees;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 5. Features de supervisor
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.features (key, name, descricao)
|
||||
VALUES
|
||||
(
|
||||
'supervisor.access',
|
||||
'Acesso à Supervisão',
|
||||
'Acesso básico ao espaço de supervisão (sala, lista de supervisionados).'
|
||||
),
|
||||
(
|
||||
'supervisor.invite',
|
||||
'Convidar Supervisionados',
|
||||
'Permite convidar terapeutas para participar da sala de supervisão.'
|
||||
),
|
||||
(
|
||||
'supervisor.sessions',
|
||||
'Sessões de Supervisão',
|
||||
'Agendamento e registro de sessões de supervisão.'
|
||||
),
|
||||
(
|
||||
'supervisor.reports',
|
||||
'Relatórios de Supervisão',
|
||||
'Relatórios avançados de progresso e evolução dos supervisionados.'
|
||||
)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name,
|
||||
descricao = EXCLUDED.descricao;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 6. plan_features — vincula features aos planos supervisor
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE
|
||||
v_free_id uuid;
|
||||
v_pro_id uuid;
|
||||
v_f_access uuid;
|
||||
v_f_invite uuid;
|
||||
v_f_sessions uuid;
|
||||
v_f_reports uuid;
|
||||
BEGIN
|
||||
SELECT id INTO v_free_id FROM public.plans WHERE key = 'supervisor_free';
|
||||
SELECT id INTO v_pro_id FROM public.plans WHERE key = 'supervisor_pro';
|
||||
|
||||
SELECT id INTO v_f_access FROM public.features WHERE key = 'supervisor.access';
|
||||
SELECT id INTO v_f_invite FROM public.features WHERE key = 'supervisor.invite';
|
||||
SELECT id INTO v_f_sessions FROM public.features WHERE key = 'supervisor.sessions';
|
||||
SELECT id INTO v_f_reports FROM public.features WHERE key = 'supervisor.reports';
|
||||
|
||||
-- supervisor_free: access + invite (limitado por max_supervisees=3)
|
||||
INSERT INTO public.plan_features (plan_id, feature_id)
|
||||
VALUES
|
||||
(v_free_id, v_f_access),
|
||||
(v_free_id, v_f_invite)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- supervisor_pro: tudo
|
||||
INSERT INTO public.plan_features (plan_id, feature_id)
|
||||
VALUES
|
||||
(v_pro_id, v_f_access),
|
||||
(v_pro_id, v_f_invite),
|
||||
(v_pro_id, v_f_sessions),
|
||||
(v_pro_id, v_f_reports)
|
||||
ON CONFLICT DO NOTHING;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 7. activate_subscription_from_intent — suporte a supervisor
|
||||
-- Supervisor = pessoal (user_id), sem tenant_id (igual therapist)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid)
|
||||
RETURNS public.subscriptions
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
declare
|
||||
v_intent record;
|
||||
v_sub public.subscriptions;
|
||||
v_days int;
|
||||
v_user_id uuid;
|
||||
v_plan_id uuid;
|
||||
v_target text;
|
||||
begin
|
||||
-- lê pela VIEW unificada
|
||||
select * into v_intent
|
||||
from public.subscription_intents
|
||||
where id = p_intent_id;
|
||||
|
||||
if not found then
|
||||
raise exception 'Intent não encontrado: %', p_intent_id;
|
||||
end if;
|
||||
|
||||
if v_intent.status <> 'paid' then
|
||||
raise exception 'Intent precisa estar paid para ativar assinatura';
|
||||
end if;
|
||||
|
||||
-- resolve target e plan_id via plans.key
|
||||
select p.id, p.target
|
||||
into v_plan_id, v_target
|
||||
from public.plans p
|
||||
where p.key = v_intent.plan_key
|
||||
limit 1;
|
||||
|
||||
if v_plan_id is null then
|
||||
raise exception 'Plano não encontrado em plans.key = %', v_intent.plan_key;
|
||||
end if;
|
||||
|
||||
v_target := lower(coalesce(v_target, ''));
|
||||
|
||||
-- ✅ supervisor adicionado
|
||||
if v_target not in ('clinic', 'therapist', 'supervisor') then
|
||||
raise exception 'Target inválido em plans.target: %', v_target;
|
||||
end if;
|
||||
|
||||
-- regra por target
|
||||
if v_target = 'clinic' then
|
||||
if v_intent.tenant_id is null then
|
||||
raise exception 'Intent sem tenant_id';
|
||||
end if;
|
||||
else
|
||||
-- therapist ou supervisor: vinculado ao user
|
||||
v_user_id := v_intent.user_id;
|
||||
if v_user_id is null then
|
||||
v_user_id := v_intent.created_by_user_id;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
if v_target in ('therapist', 'supervisor') and v_user_id is null then
|
||||
raise exception 'Não foi possível determinar user_id para assinatura %.', v_target;
|
||||
end if;
|
||||
|
||||
-- cancela assinatura ativa anterior
|
||||
if v_target = 'clinic' then
|
||||
update public.subscriptions
|
||||
set status = 'cancelled',
|
||||
cancelled_at = now()
|
||||
where tenant_id = v_intent.tenant_id
|
||||
and plan_id = v_plan_id
|
||||
and status = 'active';
|
||||
else
|
||||
-- therapist ou supervisor
|
||||
update public.subscriptions
|
||||
set status = 'cancelled',
|
||||
cancelled_at = now()
|
||||
where user_id = v_user_id
|
||||
and plan_id = v_plan_id
|
||||
and status = 'active'
|
||||
and tenant_id is null;
|
||||
end if;
|
||||
|
||||
-- duração do plano (30 dias para mensal)
|
||||
v_days := case
|
||||
when lower(coalesce(v_intent.interval, 'month')) = 'year' then 365
|
||||
else 30
|
||||
end;
|
||||
|
||||
-- cria nova assinatura
|
||||
insert into public.subscriptions (
|
||||
user_id,
|
||||
plan_id,
|
||||
status,
|
||||
started_at,
|
||||
expires_at,
|
||||
cancelled_at,
|
||||
activated_at,
|
||||
tenant_id,
|
||||
plan_key,
|
||||
interval,
|
||||
source,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
values (
|
||||
case when v_target = 'clinic' then null else v_user_id end,
|
||||
v_plan_id,
|
||||
'active',
|
||||
now(),
|
||||
now() + make_interval(days => v_days),
|
||||
null,
|
||||
now(),
|
||||
case when v_target = 'clinic' then v_intent.tenant_id else null end,
|
||||
v_intent.plan_key,
|
||||
v_intent.interval,
|
||||
'manual',
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
returning * into v_sub;
|
||||
|
||||
-- grava vínculo intent → subscription
|
||||
if v_target = 'clinic' then
|
||||
update public.subscription_intents_tenant
|
||||
set subscription_id = v_sub.id
|
||||
where id = p_intent_id;
|
||||
else
|
||||
update public.subscription_intents_personal
|
||||
set subscription_id = v_sub.id
|
||||
where id = p_intent_id;
|
||||
end if;
|
||||
|
||||
return v_sub;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 8. subscriptions_validate_scope — suporte a supervisor
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.subscriptions_validate_scope()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_target text;
|
||||
BEGIN
|
||||
SELECT lower(p.target) INTO v_target
|
||||
FROM public.plans p
|
||||
WHERE p.id = NEW.plan_id;
|
||||
|
||||
IF v_target IS NULL THEN
|
||||
RAISE EXCEPTION 'Plano inválido (target nulo).';
|
||||
END IF;
|
||||
|
||||
IF v_target = 'clinic' THEN
|
||||
IF NEW.tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura clinic não pode ter user_id (XOR).';
|
||||
END IF;
|
||||
|
||||
ELSIF v_target IN ('therapist', 'supervisor') THEN
|
||||
-- supervisor é pessoal como therapist
|
||||
IF NEW.tenant_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura % não deve ter tenant_id.', v_target;
|
||||
END IF;
|
||||
IF NEW.user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura % exige user_id.', v_target;
|
||||
END IF;
|
||||
|
||||
ELSIF v_target = 'patient' THEN
|
||||
IF NEW.tenant_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura patient não deve ter tenant_id.';
|
||||
END IF;
|
||||
IF NEW.user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura patient exige user_id.';
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
RAISE EXCEPTION 'Target de plano inválido: %', v_target;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- 9. subscription_intents_view_insert — suporte a supervisor
|
||||
-- supervisor é roteado como therapist (tabela personal)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.subscription_intents_view_insert()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
declare
|
||||
v_target text;
|
||||
v_plan_id uuid;
|
||||
begin
|
||||
select p.id, p.target into v_plan_id, v_target
|
||||
from public.plans p
|
||||
where p.key = new.plan_key;
|
||||
|
||||
if v_plan_id is null then
|
||||
raise exception 'Plano inválido: plan_key=%', new.plan_key;
|
||||
end if;
|
||||
|
||||
if lower(v_target) = 'clinic' then
|
||||
if new.tenant_id is null then
|
||||
raise exception 'Intenção clinic exige tenant_id.';
|
||||
end if;
|
||||
|
||||
insert into public.subscription_intents_tenant (
|
||||
id, tenant_id, created_by_user_id, email,
|
||||
plan_id, plan_key, interval, amount_cents, currency,
|
||||
status, source, notes, created_at, paid_at
|
||||
) values (
|
||||
coalesce(new.id, gen_random_uuid()),
|
||||
new.tenant_id, new.created_by_user_id, new.email,
|
||||
v_plan_id, new.plan_key, coalesce(new.interval,'month'),
|
||||
new.amount_cents, coalesce(new.currency,'BRL'),
|
||||
coalesce(new.status,'pending'), coalesce(new.source,'manual'),
|
||||
new.notes, coalesce(new.created_at, now()), new.paid_at
|
||||
);
|
||||
|
||||
new.plan_target := 'clinic';
|
||||
return new;
|
||||
end if;
|
||||
|
||||
-- therapist ou supervisor → tabela personal
|
||||
if lower(v_target) in ('therapist', 'supervisor') then
|
||||
insert into public.subscription_intents_personal (
|
||||
id, user_id, created_by_user_id, email,
|
||||
plan_id, plan_key, interval, amount_cents, currency,
|
||||
status, source, notes, created_at, paid_at
|
||||
) values (
|
||||
coalesce(new.id, gen_random_uuid()),
|
||||
new.user_id, new.created_by_user_id, new.email,
|
||||
v_plan_id, new.plan_key, coalesce(new.interval,'month'),
|
||||
new.amount_cents, coalesce(new.currency,'BRL'),
|
||||
coalesce(new.status,'pending'), coalesce(new.source,'manual'),
|
||||
new.notes, coalesce(new.created_at, now()), new.paid_at
|
||||
);
|
||||
|
||||
new.plan_target := lower(v_target); -- 'therapist' ou 'supervisor'
|
||||
return new;
|
||||
end if;
|
||||
|
||||
raise exception 'Target de plano não suportado: %', v_target;
|
||||
end;
|
||||
$$;
|
||||
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- FIM — verificação rápida
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
SELECT key, name, target, max_supervisees
|
||||
FROM public.plans
|
||||
WHERE target = 'supervisor'
|
||||
ORDER BY key;
|
||||
Reference in New Issue
Block a user