673 lines
25 KiB
HTML
673 lines
25 KiB
HTML
<!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>
|