## 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 / Limite entitlements: 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';

## 6. Catálogo de Planos (MVP)

Decisão fechada: MVP com 4 planos (2 targets × free/pro). Os planos antigos (ex.: `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

Observação: nomes de entitlements dependem da sua tabela de `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 `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