Agência PSI • Documento interno

Planos, Assinaturas e Seeder — Billing (MVP)

Documentação interna do domínio de Billing do SaaS multi-tenant (Agência PSI), cobrindo modelo de dados, views oficiais, catálogo de planos, princípios de produto e um seeder idempotente para instalação nova. O objetivo é impedir divergência entre UI, backend e banco (e evitar pricing nulo, upgrade quebrado e gating inconsistente).

Atualizado em: 2026-03-01 (após validações reais do schema e execução do seeder).

1. Visão geral do domínio

O Billing define o que pode e o quanto pode dentro do produto. Ele não é “uma tela de preço”: é a camada que decide limites (quantidade), habilitações (booleanos) e estado de assinatura.

Definição operacional: o Billing é composto por (1) catálogo de planos, (2) preços vigentes, (3) assinatura ativa por tenant/usuário, e (4) entitlements derivados do plano.
Objetivo do MVP: todo mundo começa no FREE (clínica e terapeuta). Paciente não é pagante; o “portal do paciente” é um recurso habilitado pelo plano do terapeuta/clínica.

2. Princípios e decisões

  • Separação rígida: Role (RBAC) não é Plano (Billing). Plano define recursos; role define permissões de acesso.
  • Planos por target: existe plano de clinic e plano de therapist. Isso impede aplicar plano errado em outro tipo de conta.
  • Tudo começa gratuito: criação de tenant atribui automaticamente um plano *_free.
  • Pricing público por View: a UI de preços deve consumir v_public_pricing (não montar preço manual no front).
  • Preço é temporal: preço tem vigência (active_from/active_to) e um “ativo atual”.
  • Seeder é padrão: nova instalação do banco deve nascer com os 4 planos do MVP + public metadata + preços PRO.
Problema real observado: a view v_public_pricing retornou preços null porque havia histórico em plan_prices mas nenhum registro vigente (todos com is_active=false e active_to preenchido).

3. Conceitos: role vs target vs plano vs feature

Role (RBAC)permissão de UI/rotas (clinic_admin, therapist, patient etc.)
Target (produto)tipo de conta: clinic ou therapist
Plano (billing)free/pro por target; é o “pacote” contratado
Feature / Limiteentitlements: booleanos e limites numéricos derivados do plano

3.1 Regra do produto: “um usuário pode ser paciente e terapeuta”

Essa regra é de identidade (um mesmo user pode estar em múltiplos contextos), mas o plano é aplicado ao tenant (clínica/terapeuta). Assim, um usuário pode:

  • estar em um tenant therapist (com therapist_free/pro)
  • estar em um tenant clinic (com clinic_free/pro)
  • acessar portal de paciente como consumidor do serviço (sem plano próprio)
Consequência: plano nunca deve ser inferido do role. O role dirige menus/rotas; o plano dirige features/limites.

4. Modelo de dados (Postgres/Supabase)

4.1 Tabelas mapeadas (schema: public)

Tabela Responsabilidade Observações práticas
plans Catálogo interno de planos (id, key, target, flags e campos legados de preço) Não usar plans.price_cents como preço público; é legado/fallback.
plan_prices Preços por intervalo e moeda, com vigência Fonte do valor monetário; a view pública agrega mensal/anual.
plan_features Entitlements por plano (limites e habilitações) Define o que o produto permite no runtime (gating).
plan_public Marketing/metadata do plano (nome público, descrição, badge, destaque, visibilidade) Direciona a tela de preços e o “tom” comercial.
plan_public_bullets Bullets de venda por plano Lista simples; a view pode agregá-las em array.
subscriptions Assinatura ativa (por tenant ou user) e status Fonte de verdade do plano vigente do tenant.
subscription_events Histórico de mudanças (old/new plan) Útil para auditoria e debug de upgrades.
subscription_intents Intenção/checkout pendente Controla upgrade antes de virar subscription.
entitlements_invalidation Invalidação de cache de entitlements Garante refresh quando plano muda.

4.2 Padrão de “preço vigente”

Armada clássica: se não existir pelo menos 1 preço vigente por (plan_id, interval, currency), a tela de pricing pode retornar null e o checkout fica sem referência.
-- Um preço é considerado vigente quando:
-- is_active = true
-- AND active_to IS NULL
-- AND now() >= active_from (se active_from existir)

5. Views oficiais (fonte de verdade)

5.1 View pública de pricing (UI deve consumir)

UI MUST: a tela de preços deve consultar v_public_pricing. Evitar compor preços no front com join manual, pois isso cria divergência e bugs silenciosos.
select
  plan_key,
  plan_name,
  public_name,
  public_description,
  badge,
  is_featured,
  is_visible,
  sort_order,
  monthly_cents,
  yearly_cents,
  monthly_currency,
  yearly_currency,
  bullets,
  plan_target
from v_public_pricing
order by plan_target, sort_order;

5.2 View de preços ativos (infra/diagnóstico)

select *
from v_plan_active_prices
order by plan_id;

5.3 View de assinatura do tenant (gating/RBAC por plano)

select *
from v_tenant_active_subscription;

5.4 View de saúde de assinaturas (debug)

select *
from v_subscription_health
where status <> 'healthy';

7. Preços (MVP) e vigência

7.1 Preços sugeridos

  • clinic_free: Grátis (sem preço, ou 0 se o front exigir número)
  • clinic_pro: mensal R$ 149 (14900), anual R$ 1490 (149000)
  • therapist_free: Grátis (sem preço, ou 0)
  • therapist_pro: mensal R$ 49 (4900), anual R$ 490 (49000)

7.2 Regras de vigência

Regra recomendada: 1 preço vigente por (plan_id, interval, currency). Para prevenir inconsistência, criar índice único parcial.
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;
Anti-padrão: encerrar todos preços e esquecer de inserir os novos. Resultado: v_public_pricing com null.

8. Seeder (nova instalação) — SQL idempotente

Objetivo do seeder: instalar (1) planos, (2) metadata pública, (3) bullets, (4) preços PRO vigentes e (opcional) (5) entitlements iniciais. O script deve ser idempotente: rodar várias vezes sem duplicar registros.

8.1 Convenções do seeder

  • Usar plans.key como chave estável (única). A view pública expõe isso como plan_key.
  • Para inserts, preferir insert ... on conflict ... do update quando houver unique constraint.
  • Para preços: encerrar preço vigente anterior e inserir um novo (ou atualizar, conforme sua política).
  • Manter source='manual' no MVP (provider pode entrar depois com Stripe).

8.2 Seeder completo (MVP)

-- ============================================================
-- SEEDER — BILLING (MVP) • SCHEMA REAL (confirmado)
-- Planos finais: clinic_free, clinic_pro, therapist_free, therapist_pro
-- Observação: v_public_pricing expõe (plan_key/plan_target), mas na tabela base é (plans.key / plans.target).
-- ============================================================

-- 0) Proteção: 1 preço vigente por (plan_id, interval, currency)
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;

-- 1) Plans (public.plans) — usa colunas reais: key, name, target
insert into plans (key, name, description, is_active, price_cents, currency, billing_interval, target)
values
  ('clinic_free',    'CLINIC FREE',    'Plano gratuito para clínicas iniciarem.', true, 0,     'BRL', 'month', 'clinic'),
  ('clinic_pro',     'CLINIC PRO',     'Plano completo para clínicas.',          true, 14900, 'BRL', 'month', 'clinic'),
  ('therapist_free', 'THERAPIST FREE', 'Plano gratuito para terapeutas.',        true, 0,     'BRL', 'month', 'therapist'),
  ('therapist_pro',  'THERAPIST PRO',  'Plano completo para terapeutas.',        true, 4900,  'BRL', 'month', 'therapist')
on conflict (key) do update
set name = excluded.name,
    description = excluded.description,
    is_active = excluded.is_active,
    price_cents = excluded.price_cents,
    currency = excluded.currency,
    billing_interval = excluded.billing_interval,
    target = excluded.target;

-- 2) Plan public (public.plan_public) — metadata de pricing
with p as (
  select id, key from plans
  where key in ('clinic_free','clinic_pro','therapist_free','therapist_pro')
)
insert into plan_public (plan_id, public_name, public_description, badge, is_featured, is_visible, sort_order)
select
  id,
  case key
    when 'clinic_free' then 'Clínica — Free'
    when 'clinic_pro' then 'Clínica — PRO'
    when 'therapist_free' then 'Terapeuta — Free'
    when 'therapist_pro' then 'Terapeuta — PRO'
  end,
  case key
    when 'clinic_free' then 'Para clínicas pequenas começarem sem cartão.'
    when 'clinic_pro' then 'Para clínicas que querem recursos completos.'
    when 'therapist_free' then 'Para começar e organizar sua prática.'
    when 'therapist_pro' then 'Para expandir com automações e escala.'
  end,
  case key
    when 'clinic_free' then 'Grátis'
    when 'therapist_free' then 'Grátis'
    else null
  end,
  case key
    when 'clinic_pro' then true
    when 'therapist_pro' then true
    else false
  end,
  true,
  case key
    when 'clinic_free' then 10
    when 'clinic_pro' then 20
    when 'therapist_free' then 10
    when 'therapist_pro' then 20
  end
from p
on conflict (plan_id) do update
set public_name = excluded.public_name,
    public_description = excluded.public_description,
    badge = excluded.badge,
    is_featured = excluded.is_featured,
    is_visible = excluded.is_visible,
    sort_order = excluded.sort_order;

-- 3) Bullets (public.plan_public_bullets) — reset simples para MVP
delete from plan_public_bullets
where plan_id in (select id from plans where key in ('clinic_free','clinic_pro','therapist_free','therapist_pro'));

insert into plan_public_bullets (plan_id, text, highlight, sort_order)
values
  ((select id from plans where key='clinic_free'),    '1 terapeuta incluído', false, 10),
  ((select id from plans where key='clinic_free'),    'Até 30 pacientes',     false, 20),
  ((select id from plans where key='clinic_free'),    'Até 100 sessões/mês',  false, 30),

  ((select id from plans where key='clinic_pro'),     'Terapeutas ilimitados', true,  10),
  ((select id from plans where key='clinic_pro'),     'Pacientes ilimitados',  true,  20),
  ((select id from plans where key='clinic_pro'),     'Relatórios e lembretes', false, 30),

  ((select id from plans where key='therapist_free'), 'Até 10 pacientes',     false, 10),
  ((select id from plans where key='therapist_free'), 'Até 40 sessões/mês',   false, 20),
  ((select id from plans where key='therapist_free'), 'Portal do paciente',   false, 30),

  ((select id from plans where key='therapist_pro'),  'Pacientes ilimitados', true,  10),
  ((select id from plans where key='therapist_pro'),  'Sessões ilimitadas',   true,  20),
  ((select id from plans where key='therapist_pro'),  'Relatórios e lembretes', false, 30);

-- 4) Preços vigentes (public.plan_prices) — somente PRO
do $$
declare
  v_clinic_pro uuid;
  v_therapist_pro uuid;
begin
  select id into v_clinic_pro from plans where key='clinic_pro';
  select id into v_therapist_pro from plans where key='therapist_pro';

  update plan_prices
  set is_active = false, active_to = now()
  where plan_id in (v_clinic_pro, v_therapist_pro)
    and is_active = true
    and active_to is null;

  insert into plan_prices (plan_id, currency, interval, amount_cents, is_active, active_from, active_to, source, provider, provider_price_id)
  values
    (v_clinic_pro, 'BRL', 'month', 14900,  true, now(), null, 'manual', null, null),
    (v_clinic_pro, 'BRL', 'year',  149000, true, now(), null, 'manual', null, null),
    (v_therapist_pro, 'BRL', 'month', 4900, true, now(), null, 'manual', null, null),
    (v_therapist_pro, 'BRL', 'year',  49000, true, now(), null, 'manual', null, null);
exception
  when unique_violation then
    raise notice 'Preço vigente já existe para algum (plan_id, interval, currency).';
end $$;

-- 5) (Opcional) Integridade: impedir apagar plano em uso
-- A FK subscriptions.plan_id -> plans.id deve estar com ON DELETE RESTRICT.
-- Se precisar aplicar:
-- alter table public.subscriptions drop constraint if exists subscriptions_plan_id_fkey;
-- alter table public.subscriptions add constraint subscriptions_plan_id_fkey
--   foreign key (plan_id) references public.plans(id) on delete restrict;

-- 6) Validação final (deve retornar 4 planos visíveis)
select plan_key, plan_name, plan_target, monthly_cents, yearly_cents
from v_public_pricing
where is_visible = true
order by plan_target, sort_order, plan_key;
Nota de adaptação: o seeder acima assume certas colunas (ex.: plans.plan_key, plans.plan_target, plans.is_active, plan_public.*). Se o seu schema tiver nomes diferentes, ajuste no primeiro uso e depois mantenha como “padrão oficial”.

8B. Entitlements — Schema real (plan_features)

O MVP usa plan_features como tabela de ligação entre plano e feature. O schema confirmado é: (plan_id uuid NOT NULL, feature_id uuid NOT NULL, enabled boolean NOT NULL default true, limits jsonb NULL).

Padrão recomendado para limits (jsonb): padronizar chaves por tipo de limite para evitar ambiguidade no front/back. Sugestão: {"max": 30} (limite absoluto), {"per_month": 40} (por período), {"max_users": 1} (limite de assentos), e manter enabled como flag binária.
Pré-requisito: para seedar entitlements, é necessário listar/definir as features na tabela de features (ex.: features). Este documento mantém os limites do MVP como referência de produto; o seeder de plan_features deve mapear essas chaves para feature_id reais.

Template (exemplo) — como gravar limites

-- Exemplo: habilitar feature X com limite max=30 para clinic_free
insert into plan_features (plan_id, feature_id, enabled, limits)
values (
  (select id from plans where key='clinic_free'),
  'FEATURE_UUID_AQUI',
  true,
  '{"max": 30}'::jsonb
);

8C. Regras de negócio confirmadas no banco

Regra confirmada: inserir subscription de clinic_* exige tenant_id. Em testes, uma tentativa de inserir assinatura de clínica sem tenant resultou em erro: “Assinatura clinic exige tenant_id.”
Consequência: assinatura de clínica é “por tenant”; assinatura de terapeuta pode ser por tenant_id ou user_id, conforme sua arquitetura — mas o banco já impõe pelo menos o caso de clínica.

9. Onboarding & Upgrade (fluxo)

9.1 Onboarding (criação de tenant)

  • Ao criar um tenant clinic → atribuir automaticamente clinic_free.
  • Ao criar um tenant therapist → atribuir automaticamente therapist_free.
  • O plano deve ser a fonte de verdade para habilitar recursos (entitlements store).

9.2 Upgrade

Upgrade é troca de plano na assinatura: *_free → *_pro. O sistema deve invalidar entitlements e atualizar cache (via entitlements_invalidation ou mecanismo equivalente).

9.3 Downgrade/expiração

No MVP, a regra segura é: ao expirar, bloquear novas criações premium, mas não apagar dados. Apenas retira capacidade.

10. Operação (runbook rápido)

Incidente comum: Pricing mostra preços nulos

  1. Rodar select * from v_public_pricing;
  2. Rodar select * from plan_prices where plan_id = ... order by created_at desc;
  3. Confirmar existência de preço vigente: is_active=true e active_to is null
  4. Se não existir, inserir preços PRO vigentes (month/year) e validar view novamente.

Incidente comum: Plano aparece errado para um tenant

  1. Verificar v_tenant_active_subscription para o tenant em questão.
  2. Verificar se o plano tem plan_target correto.
  3. Verificar se o guard/menu não está inferindo plano do role (anti-padrão).
  4. Invalidar entitlements e reavaliar.

11. Checklist de QA

  • Seeder: rodar duas vezes e confirmar que não duplica registros.
  • Pricing: v_public_pricing retorna 4 planos, com preços preenchidos para PRO.
  • Upgrade: trocar plano e confirmar mudança de entitlements no runtime.
  • FREE: criação de tenant atribui automaticamente plano free correto.
  • Target: clínica nunca recebe plano therapist (e vice-versa).
  • Vigência: inserir novo preço e confirmar que o antigo foi encerrado (active_to preenchido).

12. Prompt Mestre — Continuidade do Billing (Planos/Assinaturas)

Sempre que iniciar um novo chat sobre Billing/Planos, copie e cole este prompt. Ele representa o estado oficial do domínio e da estrutura do banco para o MVP.

Estou desenvolvendo um SaaS clínico multi-tenant usando Supabase (Postgres + RLS + Views)
com planos e assinaturas.

══════════════════════════════════════
📦 Domínio: Billing / Planos
══════════════════════════════════════

Decisões do MVP:
- Tudo começa grátis (clinic e therapist).
- Paciente não tem plano (portal do paciente é feature do plano do therapist/clinic).
- Plano (billing) NÃO é role (RBAC). Role dirige menus/rotas; plano dirige features/limites.
- Planos por target: clinic e therapist.

Catálogo de planos (MVP):
- clinic_free
- clinic_pro
- therapist_free
- therapist_pro

Views fonte de verdade:
- v_public_pricing (tela de preços)
- v_plan_active_prices (infra)
- v_tenant_active_subscription (gating por tenant)
- v_subscription_health (debug)

Tabelas principais:
- plans (colunas reais: key, target, ...)
- plan_prices (tem vigência; preço vigente: is_active=true e active_to is null; a UI usa v_plan_active_prices)
- plan_public + plan_public_bullets (marketing)
- plan_features (entitlements)
- subscriptions (+ events, intents)
- entitlements_invalidation

Preços sugeridos (MVP):
- clinic_pro: 14900/mês e 149000/ano (BRL)
- therapist_pro: 4900/mês e 49000/ano (BRL)
- free: grátis (pode manter sem preços)

Problema já observado:
- v_public_pricing retornou null quando plan_prices tinha histórico mas não tinha preço vigente.

Estado atual (confirmado):
- Apenas 4 planos existem (clinic_free/clinic_pro/therapist_free/therapist_pro)

Objetivo do próximo passo:
- Seedar plan_features (entitlements) mapeando features -> feature_id e limits jsonb para nova instalação com os 4 planos + public metadata + preços PRO vigentes.
  
Este prompt deve ser tratado como contexto estrutural completo do Billing no MVP. Qualquer solução proposta deve respeitar essa organização.

Tags

#Billing #Planos #Pricing #Seeder #Supabase #Postgres #MultiTenant #Entitlements #Subscriptions #v_public_pricing #MVP