Documento Mestre • Billing • Agência PSI

Plans, Pricing, Subscriptions & Entitlements — v2.0

Documento institucional do domínio Billing. 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).

Estado: operacional (MVP) Atualizado: 2026-03-01 10:43:18 UTC Padrão: Supabase + Postgres + Vue/PrimeVue

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).

Regra de ouro: o front nunca deve “inferir plano” por role. O plano vigente vem de subscriptions e os limites/flags vêm de plan_features.

2. Entidades e conceitos

plans

Catálogo de planos (core e custom). Guarda key, target, preço base e metadados.

plan_prices

Preço com vigência. Preço vigente: is_active=true e active_to is null.

subscriptions

Assinatura ativa por tenant (clínica) ou por user (terapeuta). A view escolhe a mais recente por owner.

features / plan_features

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
Observação importante: planos core têm guardrails: não podem ser deletados e sua 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.

Boas práticas: a tela pública não deve depender do schema “cru”. Mantenha a view como contrato.

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()
Modelagem: clínica → usa 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;
Diagnóstico rápido: se views de entitlements estiverem “vazias”, primeiro verifique se existe subscription ativa nesta view.

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)
Nota: se o RPC atualizar apenas 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.

UX operacional: o histórico deve permitir navegar de volta para o owner (Subscriptions) via query ?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
Importante: 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;
Uso no front: uma única consulta retorna 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
Armadilha comum: tentar “corrigir plano core” via UPDATE direto. O trigger bloqueia e isso é desejável.

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');
Resultado: plano core corrigido, guardrail permanece ativo. Bypass vale apenas na transação.

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;
Dica operacional: manter seed idempotente evita “duplicação” e reduz bugs em ambientes de teste.

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'
);
Validação: após inserir, 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: scripttemplatestyle.
  • Busca com FloatLabel + IconField + InputIcon.
  • Telas operacionais: DataTable com paginação, estados empty e UX “foco” via ?q=....
Melhorias aplicadas: Cards para “foco”, botão voltar no topo, textos mais claros e layout mais estável.

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).

Lembrete: quando algo “não retorna nada”, primeiro verifique as views-base antes de mexer no front.

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_json retorna mapa com allowed + limits.
  • Guardrails: triggers ativas; correção de core somente via função admin.
  • Front: telas operacionais OK; foco via query; layout consistente.
Meta: com este checklist, qualquer dev/operador consegue diagnosticar Billing em minutos.