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>

View File

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

View File

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