1. Contexto e objetivos
O MVP do SaaS precisa garantir que o sistema “respeite o plano”. Para isso, o domínio Billing opera em camadas: Plans (catálogo), Pricing (vigência), Subscriptions (plano vigente por tenant/user) e Entitlements (features + limites).
subscriptions
e os limites/flags vêm de plan_features.
2. Entidades e conceitos
Catálogo de planos (core e custom). Guarda key, target, preço base e metadados.
Preço com vigência. Preço vigente: is_active=true e active_to is null.
Assinatura ativa por tenant (clínica) ou por user (terapeuta). A view escolhe a mais recente por owner.
Mapa de capabilities e limites (limits jsonb). É daqui que o front deve “gatear” menus/ações.
3. Tabela plans e planos core
Planos core do MVP (devem existir e permanecer): clinic_free, clinic_pro, therapist_free, therapist_pro.
-- estrutura confirmada (resumo)
-- plans (public)
-- id, key, name, description, is_active, price_cents, currency, billing_interval, target
key não pode ser alterada.
4. Pricing e vigência
A UI pública de preços deve consumir v_public_pricing. A vigência de preço vem de plan_prices:
preço vigente é aquele com is_active=true e active_to is null. Para planos FREE, a UI pode exibir “Grátis”
mesmo sem registro em plan_prices.
5. Subscriptions: schema e regras
Schema confirmado via information_schema e constraints:
-- 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()
tenant_id. Terapeuta → usa user_id (com tenant_id nulo).
6. View: v_tenant_active_subscription
Esta view define “o plano vigente” do tenant. Regra: status active e período ainda válido.
Escolhe a assinatura mais recente por tenant (created_at DESC).
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;
7. Operações de assinatura: change / cancel / reactivate
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.
-- 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)
plan_id, é recomendável manter plan_key e interval
consistentes (quando for relevante), para facilitar auditoria e debugging.
8. Auditoria: subscription_events
Tela “Histórico de assinaturas” é read-only e mostra até 500 eventos mais recentes. Eventos típicos:
plan_changed, canceled, reactivated.
?q=clinic:<uuid>.
9. Features e plan_features
O MVP já possui features com keys (ex.: online_scheduling, reports_basic, etc.) e tabela plan_features:
-- 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
limits é um contrato com o front. Ex.: {"max_patients":30}, {"sessions_per_month":40}.
10. Views de entitlements (com limits)
Para atender o front com 1 query, criamos views “full” e “json”.
10.1 v_tenant_entitlements_full
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;
10.2 v_tenant_entitlements_json
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;
plan_key + mapa de entitlements com limits.
11. Triggers de proteção (Guardrails)
Triggers confirmadas em public.plans:
trg_no_delete_core_plans
trg_no_change_plan_target
trg_no_change_core_plan_key
11.1 Funções (versões base)
-- 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
12. Correção segura de plano core (bypass controlado)
Caso real desta sessão: clinic_free estava com target incorreto.
O objetivo foi corrigir sem “desligar guardrails”.
12.1 Patch do guardrail para bypass por transação
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
$$;
12.2 Função administrativa (SECURITY DEFINER)
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
$$;
12.3 Execução (caso real)
select public.admin_fix_plan_target('clinic_free', 'clinic');
12.4 Hardening recomendado
revoke execute on function public.admin_fix_plan_target(text, text) from public;
-- depois conceder apenas ao role administrativo apropriado
13. Seeder idempotente: features + plan_features
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.
-- 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;
14. Seeder idempotente: subscription de teste
Como as views dependem de uma subscription ativa, criamos uma assinatura manual de teste para um tenant real.
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'
);
v_tenant_active_subscription e v_tenant_entitlements_json devem retornar dados.
15. Front-end: padrões e telas
Padrões adotados nesta sessão:
- Em arquivos Vue: script → template → style.
- Busca com
FloatLabel+IconField+InputIcon. - Telas operacionais: DataTable com paginação, estados empty e UX “foco” via
?q=....
16. Troubleshooting (erros reais)
16.1 “Não é permitido alterar target do plano …”
Causa: trigger guard_no_change_plan_target. Solução: bypass controlado + função admin (seção 12).
16.2 “Não é permitido alterar a key do plano padrão …”
Causa: trigger guard_no_change_core_plan_key. Solução: não renomear core; criar novo plano se necessário.
16.3 Entitlements view vazia
Causa: ausência de subscription ativa em v_tenant_active_subscription. Solução: inserir subscription de teste (seção 14).
17. Checklist de validação
- Plans: core existe e está ativo; targets corretos.
- Pricing: PRO tem preço vigente (active_to null); FREE pode ficar sem price.
- Subscriptions: existe ao menos 1 assinatura ativa para testar.
- Entitlements:
v_tenant_entitlements_jsonretorna mapa comallowed+limits. - Guardrails: triggers ativas; correção de core somente via função admin.
- Front: telas operacionais OK; foco via query; layout consistente.