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