Pular para o conteúdo

0006 — Funil Comercial (proposta + contrato + assinatura + provisão)

Status

Accepted · 2026-04-26 · Autor: Alexandre + Claude. Funde os ADRs 0006/0007/0008 do v1 (PDF generation + Assinafy + public approval).

Contexto

Funil é coração comercial da plataforma. No v1, três ADRs separados cobriam: geração de PDF, integração Assinafy, fluxo de aprovação pública. No v2, unificamos em um ADR fundacional dado que tudo é coeso.

Multi-consultancy adiciona nuance: cada consultor tem seu funil isolado; clínica direta tem funil simplificado.

Decisão

1. Estágios da máquina de estados

CREATE TABLE public.leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
consultancy_id UUID REFERENCES public.consultancies(id), -- NULLABLE (lead direto)
full_name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
cnpj TEXT,
specialty TEXT,
stage TEXT NOT NULL DEFAULT 'new'
CHECK (stage IN ('new', 'form_sent', 'form_answered',
'proposal_sent', 'proposal_accepted',
'contract_sent', 'signed', 'provisioned', 'lost')),
source TEXT,
owner_user_id UUID,
lost_reason TEXT,
notes TEXT,
score INT CHECK (score IS NULL OR score BETWEEN 0 AND 100),
next_followup_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);

Stages canônicas:

new → form_sent → form_answered → proposal_sent → proposal_accepted
→ contract_sent → signed → provisioned
(qualquer estágio) → lost (com lost_reason)

Cada transição:

  • Atualiza lead.stage
  • Cria lead_activities row (audit + timeline)
  • Pode disparar email/notification

2. Onboarding (questionário)

onboarding_templates armazena questionários reutilizáveis (estrutura JSON). onboarding_submissions armazena respostas:

CREATE TABLE public.onboarding_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
consultancy_id UUID REFERENCES public.consultancies(id), -- template específico do consultor
fields JSONB NOT NULL, -- engine de forms (ADR 0005)
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE public.onboarding_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES public.onboarding_templates(id),
lead_id UUID REFERENCES public.leads(id), -- nullable (lead pode submeter antes de virar lead formal)
answers JSONB NOT NULL,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_ip INET,
user_agent TEXT
);

Lead recebe link público /onboarding/:token (token único, expira) → preenche → submete via edge function submit-onboarding-answer (sem auth).

3. Proposta

CREATE TABLE public.proposals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES public.leads(id),
consultancy_id UUID NOT NULL REFERENCES public.consultancies(id),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'sent', 'viewed', 'in_negotiation',
'accepted', 'rejected', 'expired')),
-- Componentes do plano
saas_plan_price_brl_cents INT NOT NULL, -- preço do plano (R$ 700 default)
consulting_fee_brl_cents INT, -- valor da consultoria (decisão do consultor)
consulting_installments INT,
-- Trial
trial_days INT DEFAULT 14,
-- Texto e contexto
custom_message TEXT,
saas_features_summary TEXT, -- pré-filled
consulting_scope_summary TEXT, -- preenchido pelo consultor
-- Snapshot pré-negociação (imutável após accepted)
snapshot_at_acceptance JSONB,
questioning_message TEXT, -- "tenho dúvida antes de aceitar"
questioning_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
rejected_at TIMESTAMPTZ,
rejection_reason TEXT,
expires_at TIMESTAMPTZ,
public_token TEXT UNIQUE, -- pra rota /proposta/:token
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

Proposta tem rota pública /proposta/:token (sem auth). Lead vê detalhes, escolhe:

  • ✅ “Aceitar proposta” → accepted_at setado, snapshot tirado, trigger gera contrato
  • ❓ “Tenho uma dúvida antes” → questioning_message salvo, consultor responde (UX sutil — evita rejeição direta)
  • ❌ “Recusar” → rejected_at + rejection_reason

4. Contrato

CREATE TABLE public.contracts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
proposal_id UUID NOT NULL REFERENCES public.proposals(id),
consultancy_id UUID NOT NULL REFERENCES public.consultancies(id),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'pdf_generated', 'sent_for_review',
'lead_approved', 'sent_for_signature',
'signed', 'cancelled', 'expired')),
pdf_url TEXT, -- Supabase Storage signed URL
signed_pdf_url TEXT, -- após Assinafy
-- Snapshot dos termos
proposal_snapshot JSONB NOT NULL,
contratada_legal_data JSONB NOT NULL, -- "ClinicGestor LTDA" (consultoria atendendo) — Razão social + CNPJ + endereço
contratante_legal_data JSONB NOT NULL, -- clínica
-- Assinafy
assinafy_document_id TEXT,
assinafy_envelope_id TEXT,
-- Aprovação pública (lead aprova antes de assinar)
public_approval_token TEXT UNIQUE,
approved_at TIMESTAMPTZ,
approval_request_adjustments TEXT,
-- Assinatura
signed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

5. Geração de PDF de contrato

Edge function generate-contract-pdf:

  1. Recebe contract_id
  2. Carrega snapshot dos termos
  3. Renderiza template (HTML → PDF via Puppeteer ou similar)
  4. Upload pra Supabase Storage (contract-pdfs bucket, privado)
  5. Atualiza contracts.pdf_url (signed URL com expiração)
  6. Status: draftpdf_generated

Templates parametrizados por consultoria:

  • Razão social (consultoria)
  • CNPJ
  • Endereço
  • Logo (de consultancies.branding)
  • Texto base (template padrão da plataforma)
  • Cláusulas customizáveis pelo consultor (futuro F2)

Cláusulas obrigatórias fixas pela plataforma (não removíveis):

  • Adesão ao Programa Acolhe (com 6 vagas/ano + crédito narrativo R$ 400)
  • Cobrança e fallback (regra Ideia 1)
  • Cancelamento bilateral (regra Ideia 1)
  • LGPD e consent (regra Ideia 9)

6. Aprovação pública (rota /contrato/:token)

Lead recebe email com link público (sem auth). Vê PDF do contrato. Opções:

  • ✅ “Aprovo o contrato” → approved_at + cria envelope no Assinafy → status sent_for_signature
  • 📝 “Solicitar ajuste” → approval_request_adjustments registra, consultor responde, gera novo PDF
  • ❌ “Não aprovo” → cancelled

7. Integração Assinafy

Ver ADR 0007.

8. Provisão

Após contract.status = 'signed', edge function provision-clinic:

  1. Cria registro em clinics com:
    • consultancy_id = consultoria (se attribution)
    • attribution_type = 'consultancy_acquisition' (ou marketplace_acquisition se origem marketplace)
    • attribution_consultancy_id setado (IMUTÁVEL)
    • attribution_set_at = NOW()
    • provisioned_at = NOW()
    • social_program_status = 'active' (cláusula contratual)
  2. Cria auth.users pra clinic_owner (via auth.admin.createUser)
  3. Cria clinic_member com role_key = 'clinic_owner', status = 'invited'
  4. Cria customer no Asaas DO CONSULTOR + subscription R$ 700/mês com trial de N dias (campo proposal.trial_days)
  5. Determina platform_subscription_state:
    • Trial > 0 → 'trialing' (libera acesso direto)
    • Trial = 0 → 'pending_first_payment' (espera 1ª fatura)
  6. Determina billing_via:
    • Padrão: 'consultancy_asaas' (consultor cobra clínica)
    • Plataforma direta (sem consultor): 'platform_direct' (raríssimo na F1)
  7. Envia email de welcome com magic link → owner faz primeiro login → cria senha → trial começa
  8. Atualiza lead.stage = 'provisioned'

9. Eventos do funil em audit

Cada transição em leads.stage, proposals.status, contracts.status cria linha em lead_activities:

CREATE TABLE public.lead_activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES public.leads(id),
activity_type TEXT NOT NULL CHECK (activity_type IN (
'created', 'form_sent', 'form_answered', 'proposal_drafted',
'proposal_sent', 'proposal_viewed', 'proposal_questioned',
'proposal_accepted', 'proposal_rejected', 'contract_drafted',
'contract_pdf_generated', 'contract_sent_for_review',
'contract_lead_approved', 'contract_adjustment_requested',
'contract_sent_for_signature', 'contract_signed',
'provisioned', 'lost', 'note', 'email_sent', 'sms_sent', 'call'
)),
actor_user_id UUID,
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);

CHECK constraint é whitelist viva (test db-check-drift valida).

Consequências

Positivas

  • Funil unificado com máquina de estados clara
  • Audit completo via lead_activities
  • Snapshots imutáveis no aceite (proteção legal)
  • Multi-consultancy native desde o dia 1
  • Compliance: Programa Acolhe na cláusula desde primeiro contrato

Negativas

  • 4 tabelas + edge function complexa (provision-clinic)
  • Snapshot duplica dados (necessário pra imutabilidade)

Riscos

  • Bug em provision-clinic cria estado inconsistente entre clinics + clinic_members + auth.users + Asaas
    • Mitigação: idempotência (chave: contract_id); rollback em transação Postgres + compensação pra side-effects externos
  • Stage transitions sem validação (forçar proposal_acceptedprovisioned direto, pulando contrato)
    • Mitigação: edge function valida transições permitidas

Alternativas consideradas

1. Sem snapshot — buscar dados live no momento da assinatura

  • Prós: simplicidade
  • Contras: se preços mudam entre proposta e assinatura, lead pode reclamar. Snapshot protege legalmente.
  • Rejeitado.

2. Stages como tabela em vez de enum

lead_stages table com nome + descrição.

  • Prós: mais flexível
  • Contras: stages são fundação da máquina de estados; mudar valor é refactoring grande de código. Enum + CHECK é suficiente.
  • Rejeitado.

Atualizações de documentos

  • AGENTS.md §inventário (Funil comercial)
  • docs/funil-comercial.md
  • ADRs 0007 (Assinafy)

Referências

  • ADR 0007 (Assinafy)
  • ADR 0008 (modelo financeiro)
  • ADR 0014 (Programa Acolhe — cláusula contratual)