Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions

View File

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