207 lines
5.4 KiB
HTML
207 lines
5.4 KiB
HTML
<!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>
|