Pular para conteúdo

0003 — Análise completa: taxa setup pelo analista, cláusula obrigatória e corresponsável

Status CONCLUÍDA
Branch spec/0003-analise-completa
Criada em 2026-07-26
Apps afetados api · web · e2e

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

Primeira entrega (E1) do plano por jornada do ROADMAP.md — etapa Análise / Contrato. Constrói a lógica de domínio da análise mais rica; a ativação real (assinatura Autentique + boleto) fica para o E4. Regras confirmadas pelo contrato real da TXC Garantia (docs/contratos/).

1. Problema

A análise de crédito hoje é rasa demais para refletir o que o negócio realmente decide:

  1. O analista não arbitra a taxa setup. O negócio diz que a taxa de adesão "vai do analista", mas na aprovação ele só ajusta a taxa de garantia (approved_guarantee_rate_percent). A taxa setup vem fixa do cálculo do tenant, sem espaço para o analista arbitrar por caso.
  2. Não há checkpoint da Cláusula Obrigatória. O contrato só é válido se a cláusula obrigatória estiver no contrato de locação (responsabilidade do parceiro). Hoje nada força essa verificação — dá para aprovar uma garantia que nasce inválida.
  3. Não existe corresponsável. O contrato prevê que a garantidora pode exigir um corresponsável (avalista PF, analisado, solidário). Hoje só há inquilino e proprietário — não há onde registrar o corresponsável nem somar a renda dele na análise.

2. Objetivo

Na aprovação, o analista arbitra a taxa setup e confirma a cláusula obrigatória (sem a qual não aprova). E a proposta passa a aceitar corresponsáveis, cuja renda soma no cálculo 3× — dando ao analista o quadro completo para decidir.

3. Fora de escopo

  • Ativação real (geração do documento, assinatura Autentique/biometria, boleto inicial, gate de "só ativa após assinar + pagar") — é o E4, por último.
  • Cobrança/Fechamento (E2) e Sinistro/Regresso (E3).
  • Consulta a bureaus (Rede Busca / Caixa Aqui / LOFT) — decidido fora de escopo; a análise segue com a regra 3× (agora incluindo o corresponsável).
  • Limites (min/max) para a taxa setup — decidido: valor livre, sem faixa.
  • Portal da imobiliária e RBAC 10 papéis — trilha de base, specs próprias.

4. Regras de negócio

  • RN1. Taxa setup arbitrada pelo analista. Na aprovação, o analista pode informar approved_initial_fee_value (R$, ≥ 0, livre, sem min/max). É persistido na Analysis e propaga para o snapshot Contract.initial_fee na criação do contrato (recompondo total_upfront). Se omitido, mantém o cálculo padrão do tenant (initial_fee computado hoje). Permissão: analysis:approve.
  • RN2. Cláusula Obrigatória bloqueia a aprovação. Aprovar exige mandatory_clause_confirmed = true no payload. Se false/ausente, a API responde 422 MANDATORY_CLAUSE_NOT_CONFIRMED e nada muda (proposta segue em análise). A confirmação é registrada na Analysis; o "quem/quando" é o analyst_id + o momento da decisão. Rejeição e pendência não exigem a confirmação.
  • RN3. Corresponsável é participante opcional (0..N). Novo papel ParticipantRole.CO_RESPONSIBLE, sempre pessoa física, com declared_income. Zero ou mais por proposta. Adicionado/editado/removido pelos mesmos papéis que gerenciam participantes da proposta (OPERATOR, ADMIN_TENANT, AGENCY_USER no escopo da agência). Nunca is_primary.
  • RN4. Renda do corresponsável entra na regra 3×. compute_credit_summary soma a declared_income de participantes LESSEE e CO_RESPONSIBLE. O total_declared_income, o income_to_rent_ratio, a credit_recommendation e as notas recalculam considerando os dois papéis.
  • RN5. Recálculo em análise ativa. Adicionar/remover/editar um corresponsável (ou sua renda) com uma análise ativa recomputa o resumo de crédito — mesmo comportamento que já existe ao mexer na renda do inquilino.
  • RN6. Corresponsável PF. Se o cadastro de Person distinguir PF/PJ, o participante CO_RESPONSIBLE deve ser PF; senão, 422. (Se Person não tiver o tipo hoje, fica só como docstring/validação de UI — anotar no Registro.)

5. Impacto por app

apps/api

  • Modelos/migrations:
  • models/proposal_participant.py — novo valor CO_RESPONSIBLE no enum ParticipantRole (migration Alembic para o enum participant_role).
  • models/analysis.py — novos campos approved_initial_fee_value (Numeric(12,2), nullable) e mandatory_clause_confirmed (Boolean, default false).
  • Schemas:
  • AnalysisDecisionRequest — approved_initial_fee_value: Decimal | None (ge=0) e mandatory_clause_confirmed: bool = False.
  • AnalysisResponse — expor os dois novos campos.
  • Schema de participante — aceitar role=CO_RESPONSIBLE.
  • Regras no service:
  • analysis_service.approve() — validar RN2 (bloquear sem cláusula), gravar mandatory_clause_confirmed e approved_initial_fee_value na análise.
  • Criação do contrato — usar approved_initial_fee_value como Contract.initial_fee quando presente (senão o cálculo atual), recompondo total_upfront.
  • services/credit.py compute_credit_summary — passar a considerar CO_RESPONSIBLE (ajustar a assinatura/os callers que hoje filtram só LESSEE).
  • Docstrings + Swagger — approve (novos campos e o erro MANDATORY_CLAUSE_NOT_CONFIRMED), o model Analysis, o enum ParticipantRole, e o compute_credit_summary.

apps/web

  • Página de decisão da análise (aprovação):
  • Campo "Taxa setup (R$)" opcional (data-testid="analysis-approve-setup-input").
  • Checkbox "Cláusula obrigatória incluída no contrato de locação" — (data-testid="analysis-approve-clause-checkbox"); o botão de aprovar fica desabilitado enquanto não marcado (e o backend reforça — RN2).
  • Mensagem de erro (data-testid="analysis-approve-error").
  • Formulário da proposta: seção de Corresponsável(is) (como a de inquilinos), com nome/pessoa + renda declarada, adicionar/remover.
  • data-testid: proposal-coresponsible-add, proposal-coresponsible-{i}-person-input, proposal-coresponsible-{i}-income-input, proposal-coresponsible-{i}-remove.
  • Resumo de crédito na tela da análise reflete a renda somada (inquilinos + corresponsáveis).
  • Sider: sem impacto (não é módulo novo).

apps/e2e

  • Specs:
  • Aprovar sem marcar a cláusula → bloqueado (botão desabilitado; e API responde 422).
  • Aprovar com taxa setup arbitrada → o contrato gerado tem initial_fee = valor arbitrado.
  • Proposta com renda-limite (< 3×) que passa a ≥ 3× ao adicionar um corresponsável.
  • Seeds: proposta com inquilino de renda ~2,5× o aluguel (borderline MANUAL_ANALYSIS) para exercitar o efeito do corresponsável em tests/fixtures/seeds/.

6. Contrato de API

POST /api/v1/proposals/{proposal_id}/analysis/approve
Authorization: Bearer <token>   (analysis:approve)

{
  "reason": "Aprovado — renda adequada com corresponsável.",
  "approved_guarantee_rate_percent": "12.50",   // já existente, opcional
  "approved_initial_fee_value": "300.00",        // novo, opcional (R$, livre)
  "mandatory_clause_confirmed": true             // novo, obrigatório = true p/ aprovar
}
{
  "id": "uuid",
  "status": "APPROVED",
  "approved_guarantee_rate_percent": "12.50",
  "approved_initial_fee_value": "300.00",
  "mandatory_clause_confirmed": true,
  "total_declared_income": "6000.00",
  "income_to_rent_ratio": "3.20",
  "credit_recommendation": "APPROVE"
}

Adicionar corresponsável usa o endpoint de participantes da proposta com role=CO_RESPONSIBLE (mesmo contrato dos demais participantes; o path exato é confirmado na implementação).

Erros:

HTTP Código Quando
422 MANDATORY_CLAUSE_NOT_CONFIRMED Aprovar sem mandatory_clause_confirmed=true (RN2)
422 RATE_OUT_OF_RANGE (já existente) taxa de garantia fora de min/max
422 (validação) approved_initial_fee_value negativo, ou corresponsável PJ (RN6)

7. Critérios de aceite

  • [x] CA1. Dado uma proposta em análise, quando o analista aprova sem confirmar a cláusula obrigatória, então a API responde 422 MANDATORY_CLAUSE_NOT_CONFIRMED e a proposta continua em análise (não vira APPROVED). (API: test_approve_requires_mandatory_clause; e2e: botão desabilitado.)
  • [x] CA2. Dado a aprovação com approved_initial_fee_value = 300,00, quando o contrato é criado, então Contract.initial_fee = 300,00 e total_upfront reflete esse valor. (API: test_approved_initial_fee_propagates_to_contract; e2e: setup arbitrado → contrato.)
  • [x] CA3. Dado a aprovação sem approved_initial_fee_value, então a taxa setup do contrato é a calculada pelo tenant (comportamento atual, inalterado). (API: test_default_initial_fee_used_when_not_arbitrated.)
  • [x] CA4. Dado uma proposta com um inquilino de renda 2,5× o aluguel (MANUAL_ANALYSIS), quando um corresponsável com renda suficiente é adicionado, então income_to_rent_ratio ≥ 3× e credit_recommendation = APPROVE. (API: test_co_responsible_income_counts_in_ratio; e2e: corresponsável pela UI.)
  • [x] CA5. Na tela de aprovação, o botão de aprovar fica desabilitado até a cláusula ser marcada; e o campo de taxa setup aceita valor em R$. (e2e: aprovação exige a cláusula obrigatória.)
  • [x] CA6. Um corresponsável pode ser adicionado/removido na proposta; ao remover, o resumo de crédito recalcula (RN5). (API: test_add_remove_co_responsible_recomputes_analysis.)

8. Plano de implementação

Ordem obrigatória no monorepo: api → web → e2e.

  • [x] apps/api — enum CO_RESPONSIBLE; campos approved_initial_fee_value e mandatory_clause_confirmed na Analysis; schemas
  • [x] apps/api — credit.py soma LESSEE + CO_RESPONSIBLE; approve() valida RN2 e grava setup; contrato usa o setup arbitrado
  • [x] apps/api — migration Alembic (enum + colunas)
  • [x] apps/api — docstrings + Swagger (summary, description, response_description)
  • [x] apps/api — testes: bloqueio da cláusula, setup no contrato, corresponsável no ratio
  • [x] apps/web — campos de setup + cláusula na aprovação; seção de corresponsável na proposta
  • [x] apps/e2e — spec dos 3 fluxos + seed borderline
  • [x] make test e make lint sem apontamento novo nos arquivos tocados

9. Decisões em aberto

Nenhuma — as quatro dúvidas foram resolvidas na entrevista de 2026-07-26:

  • Taxa setup = valor R$ livre (omite → padrão do tenant).
  • Cláusula obrigatória = confirmação que bloqueia a aprovação.
  • Renda do corresponsável = soma no 3×.
  • Vários corresponsáveis por proposta.

RN6 (PF do corresponsável) depende de o Person ter tipo PF/PJ — se não tiver, vira validação de UI/docstring; anotar no Registro na implementação.

10. Registro

Implementado em 2026-07-26 na branch spec/0003-analise-completa, na ordem api → web → e2e.

O que foi feito

apps/api - ParticipantRole.CO_RESPONSIBLE adicionado ao enum (models/proposal_participant.py). - Analysis.approved_initial_fee_value (Numeric(12,2), nullable) e Analysis.mandatory_clause_confirmed (Boolean, default False) (models/analysis.py). - Schemas: AnalysisDecisionRequest ganhou approved_initial_fee_value (≥ 0) e mandatory_clause_confirmed; AnalysisResponse expõe ambos; descrição de role no ParticipantInput cita CO_RESPONSIBLE. - credit.py: parâmetro renomeado para income_participants (soma LESSEE + CO_RESPONSIBLE); novo repo get_income_participants. Callers em analysis_service.create() e proposal_participant_service._recompute_credit_if_needed() passaram a usá-lo. - analysis_service.approve(): bloqueia sem mandatory_clause_confirmed (MANDATORY_CLAUSE_NOT_CONFIRMED, HTTP 422) antes de qualquer mutação; grava approved_initial_fee_value e marca a cláusula. - contract_service.create(): o setup arbitrado sobrepõe pricing.initial_fee e recompõe total_upfront. - RN3 (corresponsável nunca principal) aplicada em proposal_participant_service.add() e em proposal_service.create() (criação inline). RN5: recompute também em add()/remove(). - i18n analysis.mandatory_clause_not_confirmed (pt_BR/en_US). Swagger do approve e do add_participant atualizado. - RN6 (PF/PJ): Person.person_type (PF/PJ, default PF) + Person.cnpj; guarda CO_RESPONSIBLE_MUST_BE_PF em proposal_participant_service.add() e proposal_service.create(); PersonSummary/PersonCreate/PersonUpdate/PersonResponse expõem os campos. - Concorrência: numbering_sequence_repo.next_seq() tornou-se atômico (savepoint + retry no IntegrityError), corrigindo o HTTP 500 na criação concorrente de propostas (afetava contratos/billing também). - Migrations a1c3e5g7i9k2_analise_completa (enum CO_RESPONSIBLE + colunas da Analysis) e b2d4f6h8j0m3_person_type_pf_pj (enum person_type + cnpj) — ambas aplicadas e testadas em ida/volta no PostgreSQL real. - 7 testes novos em tests/test_analise_completa.py; suíte: 157 passando.

apps/web - AnalysisPanel: campo "Taxa setup (R$)" (analysis-approve-setup-input), checkbox da cláusula (analysis-approve-clause-checkbox) que desabilita o botão de aprovar (analysis-approve-button) até ser marcada, erro (analysis-approve-error), e leitura da taxa setup aprovada no estado terminal. - api.ts: CO_RESPONSIBLE no tipo, campos novos em Analysis, approveAnalysis estendido. - Formulário da proposta (create.tsx): seção Corresponsáveis com linhas adicionar/remover (proposal-coresponsible-add, -{i}-person-input, -{i}-income-input, -{i}-remove). edit.tsx: grupo de corresponsável no editor de participantes; show.tsx passou a renderizar o grupo. i18n pt_BR/en_US. - ConfirmDialog: adicionados confirm-dialog, confirm-dialog-confirm, confirm-dialog-cancel, confirm-dialog-reason-input (faltavam e o e2e precisava). - pnpm build (tsc + vite) limpo.

apps/e2e - tests/e2e/proposals-analysis.spec.ts — 3 fluxos (cláusula bloqueia; setup → contrato; corresponsável pela UI eleva a análise a ≥ 3×). Setup do mundo via API; UI exercida na tela de aprovação e na seção de corresponsável. 3 passando (chromium). - Seed tests/fixtures/seeds/proposals/borderline.json.

Divergências do plano e decisões

  • RN6 (corresponsável PF) — implementada de fato (decisão do usuário em 2026-07-26): o Person ganhou person_type (PF/PJ, default PF) e cnpj (migration b2d4f6h8j0m3, aplicada e testada em ida/volta). O backend bloqueia adicionar um corresponsável PJ (CO_RESPONSIBLE_MUST_BE_PF, HTTP 422) tanto no endpoint de participantes quanto na criação inline da proposta. No web, o cadastro de pessoa tem seletor PF/PJ + campo CNPJ (com máscara/validação), e as listas de seleção de corresponsável filtram para PF. Teste test_co_responsible_must_be_pf. i18n proposal.co_responsible_must_be_pf. (Superou a §9, que admitia deixar só como docstring.)
  • CA6 — recompute na remoção: coberto por teste de API (add/remove recomputam quando há análise ativa em IN_PROGRESS/PENDING). Pela UI, a edição de participantes é DRAFT-only (a tela de edição bloqueia outros status), então o e2e demonstra o corresponsável somando renda via a UI em DRAFT + análise aberta na sequência; o recompute dinâmico em análise ativa fica coberto pelo teste de API. Melhoria futura: permitir editar participantes em PENDING_DOCS pela UI para refletir o recompute ao vivo.
  • Bug de concorrência corrigido: criar propostas concorrentemente dava HTTP 500 — corrida no next_seq da numeração (proposal_number): duas transações liam None e inseriam a mesma linha. Corrigido em numbering_sequence_repo.py — o insert roda num savepoint (begin_nested) e, no IntegrityError da constraint única, cai no caminho de incremento (a linha já existe). Corrige também contratos/billing (mesmo mecanismo). O e2e roda os três fluxos em paralelo (3 workers) sem 500, comprovando a correção.
  • Lint: ruff nos arquivos tocados só acusa E501 (estilo herdado, o projeto nunca rodou ruff format) — nenhum apontamento novo de outra categoria; a migration ficou 100% limpa. tsc do web limpo.