309 lines
6.6 KiB
HTML
309 lines
6.6 KiB
HTML
|
|
<!doctype html>
|
|
<html lang="pt-BR">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<title>Arquitetura Técnica Completa — Billing & Assinaturas | Agência PSI</title>
|
|
<style>
|
|
body{font-family:Arial,Helvetica,sans-serif;margin:40px;line-height:1.65;color:#111}
|
|
h1,h2,h3{margin-top:36px}
|
|
code,pre{background:#f4f4f4;padding:12px;border-radius:8px;display:block;overflow:auto;font-size:13px}
|
|
.section{margin-bottom:40px}
|
|
.small{font-size:13px;color:#555}
|
|
ul{margin-left:20px}
|
|
.diagram{background:#fafafa;border:1px solid #ddd;padding:16px;border-radius:8px;font-family:monospace;font-size:13px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>Arquitetura Técnica Completa — Billing & Assinaturas</h1>
|
|
<p class="small">Projeto: Agência PSI • Documento estrutural definitivo do domínio de Billing.</p>
|
|
|
|
<div class="section">
|
|
<h2>1. Visão Arquitetural Geral</h2>
|
|
|
|
<div class="diagram">
|
|
USUÁRIO
|
|
│
|
|
▼
|
|
subscription_intents (VIEW unificada)
|
|
│
|
|
▼ (RPC activate_subscription_from_intent)
|
|
subscriptions
|
|
│
|
|
▼
|
|
subscription_events (auditoria)
|
|
│
|
|
▼
|
|
entitlements (derivados do plano)
|
|
</div>
|
|
|
|
<p>Separação estrutural:</p>
|
|
<ul>
|
|
<li><strong>plans</strong> → catálogo</li>
|
|
<li><strong>plan_prices</strong> → preço versionado</li>
|
|
<li><strong>subscription_intents_*</strong> → intenção pré-pagamento</li>
|
|
<li><strong>subscriptions</strong> → assinatura ativa</li>
|
|
<li><strong>subscription_events</strong> → histórico</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>2. Estados Oficiais da Assinatura</h2>
|
|
<pre>
|
|
pending
|
|
active
|
|
past_due
|
|
suspended
|
|
cancelled
|
|
expired
|
|
</pre>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>3. Índice de Integridade de Preços</h2>
|
|
<pre>
|
|
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;
|
|
</pre>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>4. Função Completa — activate_subscription_from_intent</h2>
|
|
<pre>
|
|
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$;
|
|
</pre>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>5. Função Completa — transition_subscription (segura)</h2>
|
|
<pre>
|
|
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;
|
|
$$;
|
|
</pre>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>6. Máquina de Estados Recomendada</h2>
|
|
|
|
<div class="diagram">
|
|
pending → active → past_due → suspended → cancelled
|
|
↓
|
|
expired
|
|
</div>
|
|
|
|
<p>Recomendação futura: validar allowed_transitions em tabela dedicada.</p>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>7. Checklist de Validação Estrutural</h2>
|
|
<ul>
|
|
<li>Intent paga gera subscription ativa</li>
|
|
<li>subscription_id vinculado corretamente</li>
|
|
<li>Cancelamento gera evento</li>
|
|
<li>Reativação preserva histórico</li>
|
|
<li>Tenant isolation validado</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>8. Roadmap Estrutural Futuro</h2>
|
|
<ul>
|
|
<li>State machine formal com allowed_transitions</li>
|
|
<li>Automação de expiração por cron</li>
|
|
<li>Integração Stripe mantendo arquitetura</li>
|
|
<li>Health monitor automatizado</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<hr/>
|
|
<p class="small">Documento técnico estrutural consolidado após implementação real validada.</p>
|
|
|
|
</body>
|
|
</html>
|