## 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: script → template → style.
- 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.