Arquitetura Técnica Completa — Billing & Assinaturas

Projeto: Agência PSI • Documento estrutural definitivo do domínio de Billing.

1. Visão Arquitetural Geral

USUÁRIO │ ▼ subscription_intents (VIEW unificada) │ ▼ (RPC activate_subscription_from_intent) subscriptions │ ▼ subscription_events (auditoria) │ ▼ entitlements (derivados do plano)

Separação estrutural:

2. Estados Oficiais da Assinatura

pending
active
past_due
suspended
cancelled
expired

3. Índice de Integridade de Preços

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;

4. Função Completa — activate_subscription_from_intent

CREATE OR REPLACE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid)
RETURNS subscriptions
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
declare
  v_intent record;
  v_sub public.subscriptions;
  v_days int;
  v_user_id uuid;
  v_plan_id uuid;
  v_target text;
begin

  select * into v_intent
  from public.subscription_intents
  where id = p_intent_id;

  if not found then
    raise exception 'Intent não encontrada';
  end if;

  if v_intent.status <> 'paid' then
    raise exception 'Intent precisa estar paid';
  end if;

  select p.id, p.target
    into v_plan_id, v_target
  from public.plans p
  where p.key = v_intent.plan_key
  limit 1;

  if v_plan_id is null then
    raise exception 'Plano não encontrado';
  end if;

  v_target := lower(coalesce(v_target,''));

  if v_target = 'clinic' and v_intent.tenant_id is null then
    raise exception 'Intent clinic exige tenant_id';
  end if;

  if v_target = 'therapist' and v_intent.tenant_id is not null then
    raise exception 'Intent therapist não deve ter tenant_id';
  end if;

  v_days := case when v_intent.interval = 'year' then 365 else 30 end;

  v_user_id := coalesce(v_intent.created_by_user_id, v_intent.user_id);

  if v_user_id is null then
    raise exception 'user_id obrigatório';
  end if;

  if v_target = 'clinic' then
    update subscriptions
    set status = 'cancelled',
        cancelled_at = now()
    where tenant_id = v_intent.tenant_id
      and status = 'active';
  else
    update subscriptions
    set status = 'cancelled',
        cancelled_at = now()
    where user_id = v_user_id
      and tenant_id is null
      and status = 'active';
  end if;

  insert into subscriptions (
    user_id,
    plan_id,
    status,
    current_period_start,
    current_period_end,
    tenant_id,
    plan_key,
    interval,
    provider,
    started_at,
    activated_at
  )
  values (
    v_user_id,
    v_plan_id,
    'active',
    now(),
    now() + make_interval(days => v_days),
    case when v_target='clinic' then v_intent.tenant_id else null end,
    v_intent.plan_key,
    v_intent.interval,
    'manual',
    now(),
    now()
  )
  returning * into v_sub;

  return v_sub;

end;
$function$;

5. Função Completa — transition_subscription (segura)

create or replace function public.transition_subscription(
  p_subscription_id uuid,
  p_to_status text,
  p_reason text default null,
  p_metadata jsonb default null
)
returns subscriptions
language plpgsql
security definer
as $$
declare
  v_sub subscriptions;
  v_uid uuid;
  v_allowed boolean := false;
begin

  v_uid := auth.uid();

  select * into v_sub
  from subscriptions
  where id = p_subscription_id;

  if not found then
    raise exception 'Assinatura não encontrada';
  end if;

  if is_saas_admin() then
    v_allowed := true;
  end if;

  if not v_allowed
     and v_sub.tenant_id is null
     and v_sub.user_id = v_uid then
    v_allowed := true;
  end if;

  if not v_allowed
     and v_sub.tenant_id is not null then

    if exists (
      select 1 from tenant_members tm
      where tm.tenant_id = v_sub.tenant_id
        and tm.user_id = v_uid
        and tm.status = 'active'
        and tm.role = 'tenant_admin'
    ) then
      v_allowed := true;
    end if;

  end if;

  if not v_allowed then
    raise exception 'Sem permissão';
  end if;

  update subscriptions
  set status = p_to_status,
      updated_at = now()
  where id = p_subscription_id
  returning * into v_sub;

  insert into subscription_events (
    subscription_id,
    owner_id,
    event_type,
    created_at,
    created_by,
    source,
    reason,
    metadata
  )
  values (
    v_sub.id,
    coalesce(v_sub.tenant_id, v_sub.user_id),
    'status_changed',
    now(),
    v_uid,
    'manual_transition',
    p_reason,
    p_metadata
  );

  return v_sub;

end;
$$;

6. Máquina de Estados Recomendada

pending → active → past_due → suspended → cancelled ↓ expired

Recomendação futura: validar allowed_transitions em tabela dedicada.

7. Checklist de Validação Estrutural

8. Roadmap Estrutural Futuro


Documento técnico estrutural consolidado após implementação real validada.