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:
672
docs/billing/Agencia_PSI_Billing_Mestre_v2_0.html
Normal file
672
docs/billing/Agencia_PSI_Billing_Mestre_v2_0.html
Normal 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 > 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>
|
||||
206
docs/billing/Agencia_PSI_Billing_Subscriptions_v1_2.html
Normal file
206
docs/billing/Agencia_PSI_Billing_Subscriptions_v1_2.html
Normal 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>
|
||||
308
docs/billing/documentacao-billing-completa-agencia-psi.html
Normal file
308
docs/billing/documentacao-billing-completa-agencia-psi.html
Normal 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>
|
||||
Reference in New Issue
Block a user