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.
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
clinice plano detherapist. 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.
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
clinic ou therapist3.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)
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”
(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)
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';
6. Catálogo de Planos (MVP)
pro, plano_2) podem ser descontinuados e ficar invisíveis.
| plan_key | target | Tipo | Objetivo | Notas de produto |
|---|---|---|---|---|
clinic_free |
clinic |
FREE | Entrada de clínicas pequenas (começar sem cartão) | Usável, mas com teto claro para gerar upgrade natural. |
clinic_pro |
clinic |
PRO | Clínica completa | Habilita secretária, relatórios, automações etc. (conforme evolução). |
therapist_free |
therapist |
FREE | Entrada de terapeuta solo | Permite operar, mas limita escala (pacientes/sessões). |
therapist_pro |
therapist |
PRO | Profissional estabelecido | Expande limites e libera automações/relatórios conforme roadmap. |
6.1 Limites sugeridos (MVP — ajustável)
| Entitlement | clinic_free | clinic_pro | therapist_free | therapist_pro |
|---|---|---|---|---|
therapists_limit |
1 | ilimitado | — | — |
patients_limit |
30 | ilimitado | 10 | ilimitado |
sessions_month_limit |
100 | ilimitado | 40 | ilimitado |
secretary_enabled |
false | true | — | — |
reports_enabled |
false | true | false | true |
reminders_enabled |
false | true | false | true |
patient_portal_enabled |
true | true | true | true |
features (se existir).
A lógica do seeder abaixo separa “chaves sugeridas” da implementação final.
7. Preços (MVP) e vigência
7.1 Preços sugeridos
- clinic_free: Grátis (sem preço, ou
0se 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
(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;
v_public_pricing com null.
8. Seeder (nova instalação) — SQL idempotente
8.1 Convenções do seeder
- Usar
plans.keycomo chave estável (única). A view pública expõe isso comoplan_key. - Para inserts, preferir
insert ... on conflict ... do updatequando 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;
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).
{"max": 30} (limite absoluto),
{"per_month": 40} (por período),
{"max_users": 1} (limite de assentos),
e manter enabled como flag binária.
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
clinic_* exige tenant_id.
Em testes, uma tentativa de inserir assinatura de clínica sem tenant resultou em erro:
“Assinatura clinic exige tenant_id.”
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 automaticamenteclinic_free. - Ao criar um tenant
therapist→ atribuir automaticamentetherapist_free. - O plano deve ser a fonte de verdade para habilitar recursos (entitlements store).
9.2 Upgrade
*_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
- Rodar
select * from v_public_pricing; - Rodar
select * from plan_prices where plan_id = ... order by created_at desc; - Confirmar existência de preço vigente:
is_active=trueeactive_to is null - Se não existir, inserir preços PRO vigentes (month/year) e validar view novamente.
Incidente comum: Plano aparece errado para um tenant
- Verificar
v_tenant_active_subscriptionpara o tenant em questão. - Verificar se o plano tem
plan_targetcorreto. - Verificar se o guard/menu não está inferindo plano do role (anti-padrão).
- Invalidar entitlements e reavaliar.
11. Checklist de QA
- Seeder: rodar duas vezes e confirmar que não duplica registros.
- Pricing:
v_public_pricingretorna 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)
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.