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>
|
||||
Reference in New Issue
Block a user