Pular para conteúdo

0016 — Renda comprovada do inquilino principal não é obrigatória

Status CORRIGIDO
Branch fix/0016-renda-do-inquilino-nao-e-obrigatoria
Aberto em 2026-08-30
Severidade MÉDIA
Apps afetados api · web
Revisão do diagnóstico —
Revisão da correção —

Status: RASCUNHO → DIAGNOSTICADO → EM CORREÇÃO → CORRIGIDO

O fix atravessa dois portões, cada um revisado por um subagente de contexto limpo, em até 3 rodadas. Máquina do plugin superpowers, régua em .claude/rubrics/. Qualquer Critical reprova; Important em aberto reprova salvo decisão registrada do usuário.

1. Sintoma

No formulário de Análise de Crédito (apps/web), o campo "Renda comprovada (R$)" do inquilino principal não é obrigatório: o usuário salva o rascunho, edita e chama POST /credit-analyses/{id}/submit sem nunca ter preenchido a renda, e a API só recusa nessa última chamada — e mesmo assim só se nenhum dos LESSEEs da análise tiver renda declarada (basta um, mesmo que não seja o principal). O campo em si não tem indicação visual de obrigatoriedade, não valida no cliente e a única mensagem existente é um helperText estático ("Do inquilino principal. A dos demais participantes soma no indicador."), nunca um erro.

Risco de negócio descrito pela própria spec 0008 para o requisito documental equivalente vale aqui também: aprovar uma garantia sem renda comprovada do inquilino principal desloca o prejuízo para o sinistro.

2. Reprodução

  1. Autenticado como ADMIN_TENANT, OPERATOR ou AGENCY_USER — os três papéis com credit_analyses:create no seed de RBAC (apps/api/app/db/rbac_seed.py:169,230,277); o ANALYST não tem essa permissão (só update/approve/reject, linhas 199-224), então não consegue nem abrir o passo 1 —, abrir Análise de Crédito → Nova.
  2. Preencher os dados do inquilino principal e o Pacote Locatício, deixando "Renda comprovada (R$)" em branco.
  3. Salvar — a API responde 201, sem qualquer aviso.
  4. Chamar POST /credit-analyses/{id}/submit — a API só barra com 422 LESSEE_NO_INCOME se nenhum LESSEE da análise tiver renda; com um segundo LESSEE que tenha renda preenchida, o submit passa mesmo com o principal em branco.

  5. Ambiente: local / dev-remote — reproduzível em qualquer um, é regra de validação, não depende de dado de produção.

  6. Superfície: desktop e mobile — o campo e a ausência de validação são os mesmos nos dois breakpoints (mesmo componente TextField, mesmo fluxo de validate()).
  7. Dados/pré-condições: qualquer tenant; papel com credit_analyses:create (ADMIN_TENANT, OPERATOR ou AGENCY_USER — não ANALYST, que só tem update/approve/reject).

3. Esperado × Atual

  • Esperado: o campo "Renda comprovada (R$)" do inquilino principal é obrigatório — visualmente marcado como tal, com mensagem de erro inline quando vazio, e a API recusa criar ou editar esse participante sem o valor.
  • Atual: o campo é opcional em todas as camadas; a única checagem existe no submit e é satisfeita por qualquer LESSEE ter renda, não necessariamente o principal.

4. Causa raiz

  • Onde:
  • apps/api/app/schemas/credit_analysis_participant.py:32 (ParticipantInput) e :40 (ParticipantUpdate) — declared_income: Decimal | None = Field(None, gt=0, ...): default None, sem exigência mesmo quando role == LESSEE e is_primary == True.
  • apps/api/app/services/credit_analysis_participant_service.py — add() (linha 104) e update() (linha 182) não chamam nenhuma validação de renda; a única existente, _validate_participants_for_submit (apps/api/app/services/credit_analysis_service.py:781-797), só roda em submit() e usa any(p.declared_income for p in lessees) — passa com um só LESSEE preenchido, independente de ser o principal.
  • apps/api/app/models/credit_analysis_participant.py:89 — coluna nullable=True, coerente com o schema acima.
  • apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx:1710-1723 — TextField do campo "Renda comprovada (R$)" sem required e sem error/mensagem ligada a estado de validação (diferente do campo vizinho "Aluguel mensal (R$)", que tem required na linha 1638-1639).
  • apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx:655-701 — função validate() do formulário: valida rental (linha 693) e months (linha 694), mas não existe entrada equivalente para income.

  • O mecanismo: o schema Pydantic declara o campo opcional e sem Field(...) obrigatório; como nada na camada de serviço de participante (add/update) reforça a regra, a única barreira do sistema inteiro é a checagem fraca do submit(), que usa any() em vez de checar especificamente o inquilino principal. No apps/web, o TextField nunca recebeu required nem entrou na função validate() quando o formulário foi escrito — por isso não há mensagem de erro nem bloqueio de salvamento; o valor vazio é convertido para null explicitamente no envio (CreditAnalysisForm.tsx:794 e ocorrências análogas) e viaja assim até o banco, onde a coluna aceita NULL. As três camadas (schema, service, form) concordam em tratar o campo como opcional — não há divergência entre elas, é a mesma decisão original repetida três vezes, e é essa decisão que este fix reverte.

  • O alcance: a mesma coluna e o mesmo schema são usados tanto na criação quanto na edição do participante (ParticipantInput e ParticipantUpdate têm o mesmo padrão), e tanto para LESSEE quanto para CO_RESPONSIBLE — mas por decisão do usuário (seção 10), o escopo deste fix é só o LESSEE principal; os demais papéis continuam com o campo opcional, e isso é decisão de escopo, não descoberta de mais um lugar afetado. Além dos dois schemas e do TextField citados, há pontos de leitura de declared_income que não precisam mudar — CreditAnalysisForm.tsx:384-396 (pré-preenchimento do formulário) e show.tsx:577,1040,1063 (exibição) — e credit.py:55-85 (compute_credit_summary), que apenas tolera None ao somar. Nenhum deles grava o valor; a única escrita sem validação é a do add()/update() já citados, e é ali que a correção entra.

Ponto que a correção da seção 6 precisa respeitar, não contornar: declared_income já está deliberadamente fora do FIELD_MAP genérico de erro-por-campo (CreditAnalysisForm.tsx:512-517, RN13d, spec 0046) — a API monta o nome do campo com loc[-1] sem o índice do participante, então a renda do participante 0 e a do 3 chegam ao front idênticas, e apontar o campo errado seria pior que não apontar. Essa exclusão continua válida para o erro genérico de schema (gt=0 recusando valor ≤ 0, por exemplo) e não muda. A validação nova deste fix é outra coisa: um código de erro de negócio dedicado (PRIMARY_LESSEE_INCOME_REQUIRED, seção 6), que só existe quando o participante em questão é o principal — e só há um principal por análise —, então não há ambiguidade de índice para resolver. O tratamento desse código no front não entra no FIELD_MAP de loc[-1]; é um if à parte no tratamento da resposta de add()/update() do participante principal, apontando direto para o campo de renda.

5. Revisão do diagnóstico

Preenchido pelo laço do /fix new, rubrica .claude/rubrics/diagnostico.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 1 (citação arquivo:linha errada) 0 1 reprovado
2 0 2 (papel de reprodução sem a permissão; alcance não citava a exclusão RN13d) 1 reprovado
3 0 0 0 aprovado

Resultado: aprovado na rodada 3.

Pendências aceitas: vazio.

6. Correção

apps/api

  • Onde: services/credit.py (nova função exigir_renda_do_principal), services/credit_analysis_participant_service.py (add() e update()) e services/credit_analysis_service.py (create(), laço de participantes). O schema schemas/credit_analysis_participant.py não mudou — o campo continua opcional ali, porque a obrigatoriedade depende de role/is_primary, que o schema não decide. [DIVERGÊNCIA DO PLANO] a seção 4 original não citava credit_analysis_service.py:create() como terceiro ponto de escrita — apareceu ao implementar: a criação da análise monta CreditAnalysisParticipant direto, sem passar por add(), e ficaria de fora se só os dois métodos do serviço de participante fossem cobertos. Registrado na seção 12.
  • O quê: função pura exigir_renda_do_principal(role, is_primary, declared_income) em credit.py, chamada nos três pontos que decidem o is_primary final de um participante; recusa (422 PRIMARY_LESSEE_INCOME_REQUIRED) se o resultado é um LESSEE principal sem renda. Sem migration — a coluna já é Numeric(12,2) nullable=True; a obrigatoriedade é de validação de negócio (dado legado em DRAFT sem renda continua existindo e só bloqueia na próxima edição). Swagger dos três endpoints (POST /credit-analyses, POST/PATCH .../participants) atualizado com o código novo.

apps/web

  • Onde: pages/credit-analyses/CreditAnalysisForm.tsx — TextField da renda (linhas ~1710-1723) e a função validate() (linhas 655-705).
  • O quê: marcar o campo como obrigatório visualmente (required, mesmo padrão do campo "Aluguel mensal"), adicionar a checagem em validate() para o inquilino principal e mensagem de erro inline (ex. "Informe a renda comprovada do inquilino principal"). Para o código de erro dedicado que a API passa a devolver (PRIMARY_LESSEE_INCOME_REQUIRED), tratar fora do FIELD_MAP genérico de loc[-1] (que continua excluindo declared_income por RN13d — seção 4) — um tratamento à parte na resposta de add()/update() do participante principal, que aponta direto para o campo, seguindo o espírito do fix 0015 (recusa aparece no campo, não só no topo) sem reabrir a ambiguidade de índice que a RN13d resolveu.
  • data-testid: o TextField já tem data-testid="credit-analysis-tenant-0-income-input"; acrescentar testid no texto de erro se o padrão do formulário exigir (conferir outros campos obrigatórios do mesmo arquivo).
  • Responsividade & PWA: validar em desktop e mobile — é o mesmo componente, sem CSS condicional a breakpoint; sem impacto de PWA (não é asset estático).

apps/e2e

  • Sem spec novo. A cobertura da regra em si ficou no pytest (API) e no CreditAnalysisForm.test.tsx (web) — os dois provam o comportamento com red-green. O e2e entrou só como verificação de não-regressão das telas tocadas: rodar os specs existentes de Análise de Crédito (credit-analyses-form-steps, credit-analyses, credit-analyses-mobile) contra o dev-remote depois do rebuild, porque agora exigem a renda para gravar. Um deles (CA5/CA6/CA7 — cadastro inline...) parou de conseguir gravar sem ela — ajustado para preencher o campo antes de salvar (seção 12).

docs/fixes/assets/0016-renda-do-inquilino-nao-e-obrigatoria/ fica reservado para print/evidência, se necessário — nenhum anexado até aqui.

7. Risco / impacto colateral

  • Dados existentes em DRAFT sem declared_income no principal continuam gravados (sem migration/backfill); a regra nova só passa a valer na próxima vez que o participante for editado ou adicionado, não retroativamente. Uma análise antiga em DRAFT pode, na teoria, seguir para submit() sem que a edição do participante tenha sido reexecutada — mas a checagem de _validate_participants_for_submit (seção 4) permanece como segunda barreira nesse caminho.
  • compute_credit_summary (credit.py:55-85) não muda de comportamento: já tolera None, e com a obrigatoriedade do principal deve, na prática, sempre receber um valor — não há mudança de cálculo, só de garantia de dado de entrada. Nenhum impacto em billing_service.py ou decision_service.py, que não leem declared_income diretamente.
  • CO_RESPONSIBLE e LESSEE não-principal seguem opcionais — é decisão de escopo do usuário (seção 10), não lacuna descoberta durante a correção.

8. Critérios de aceite

  • [x] CA1. Seguindo os passos da seção 2, tentar salvar/editar o inquilino principal sem renda comprovada é recusado pela API e o campo mostra erro inline no formulário — o bug não reproduz mais. (test_criar_analise_recusa_principal_sem_renda, test_adicionar_participante_principal_sem_renda, test_promover_participante_a_principal_sem_renda; web: CreditAnalysisForm.test.tsx — "acusa o campo vazio ao tentar gravar")
  • [x] CA2. Um LESSEE não-principal ou um CO_RESPONSIBLE continuam podendo ser salvos sem declared_income (escopo do fix não os alcança). (test_criar_analise_aceita_lessee_secundario_sem_renda)
  • [x] CA3. Uma Análise de Crédito com o inquilino principal já tendo renda preenchida segue funcionando sem regressão (criação, edição, submit, cálculo de capacidade). (suíte completa da API — 779 verdes; suíte completa do web — 115 verdes; e2e das telas tocadas — 22 verdes)

9. Plano

  • [x] Escrever o teste que reproduz o bug e vê-lo falhar (apps/api/tests/, e apps/web/**/*.test.tsx se o comportamento do formulário pedir) — antes de qualquer correção
  • [x] Corrigir a causa raiz — na ordem api → web (sem impacto direto em e2e previsto — confirmado: os specs existentes precisaram só de um ajuste, não de spec novo)
  • [x] Red-green verificado: teste passa → reverter a correção → rodar → falha → restaurar → passa (API e web, separadamente — evidência na seção 12)
  • [x] make test e make lint sem apontamento novo nos arquivos tocados
  • [x] Validar no browser de verdade em desktop e mobile (e2e cobre os dois viewports)

10. Decisões em aberto

Resolvidas nesta rodada, com o usuário:

  • Escopo: obrigatório só para o inquilino principal (role=LESSEE, is_primary=True). LESSEEs adicionais e CO_RESPONSIBLE continuam opcionais.
  • Momento: a API passa a recusar já ao adicionar ou editar o participante (POST/PATCH), não só no submit() — o rascunho nunca fica com o principal sem renda.

Nenhuma pendente.

11. Revisão da correção

Preenchido pelo laço do /fix implement, rubrica .claude/rubrics/correcao.md. Registrar todas as rodadas.

Rodada Critical Important Minor Veredicto
1 0 0 1 (referência de teste desatualizada no front matter) aprovado

O teste sobreviveria à reversão? Sim, confirmado por red-green de verdade (não só lido): API — revertidos credit.py/credit_analysis_service.py/credit_analysis_participant_service.py, os três testes novos falharam com 201/200 no lugar do 422 PRIMARY_LESSEE_INCOME_REQUIRED esperado; restaurada a correção, voltaram a passar. Web — revertido CreditAnalysisForm.tsx, o caso "acusa o campo vazio ao tentar gravar" falhou (mensagem ausente); restaurada a correção, voltou a passar. Evidência completa na seção 12.

Defeitos aceitos: o Minor (referência de teste desatualizada no related.tests do front matter) foi corrigido na hora, não ficou como pendência.

12. Registro

O que era: declared_income do inquilino principal (LESSEE, is_primary=True) era opcional em toda camada — schema, service e formulário —, e a única checagem do sistema inteiro (_validate_participants_for_submit, no submit()) usava any() sobre todos os LESSEEs, satisfeita por um participante secundário com renda mesmo que o principal não tivesse nenhuma.

O que virou: função pura exigir_renda_do_principal() em apps/api/app/services/credit.py, chamada nos três pontos que decidem o is_primary final de um participante — CreditAnalysisService.create(), CreditAnalysisParticipantService.add() e .update() —, recusando com 422 PRIMARY_LESSEE_INCOME_REQUIRED sempre que o resultado é um LESSEE principal sem renda. No apps/web, CreditAnalysisForm.tsx ganhou required no campo, checagem em validate() com mensagem inline ("Informe a renda comprovada do inquilino principal") e tratamento dedicado do novo código de erro da API, fora do FIELD_MAP genérico (que continua excluindo declared_income por RN13d — a ambiguidade de índice não se aplica ao código novo porque ele só existe para o principal, e há só um por análise).

Divergência do plano: a seção 4/6 original não citava credit_analysis_service.py:create() como terceiro ponto de escrita — apareceu ao implementar, porque a criação da análise monta CreditAnalysisParticipant direto no laço, sem passar por add(). Ficaria de fora, e uma análise nova continuaria podendo nascer sem a renda do principal. Coberto pelo mesmo teste (test_criar_analise_recusa_principal_sem_renda) e pela mesma função compartilhada.

Red-green, com evidência: - API — revertidos credit.py, credit_analysis_service.py e credit_analysis_participant_service.py (git stash); rodado tests/test_credit_analyses.py no dev-remote: os três testes novos (test_criar_analise_recusa_principal_sem_renda, test_adicionar_participante_principal_sem_renda, test_promover_participante_a_principal_sem_renda) falharam com 201/200 no lugar do 422 esperado. Restaurada a correção, os 13 testes do arquivo voltaram a passar. - Web — revertido CreditAnalysisForm.tsx (git stash); rodado make test-web ARQ=.../CreditAnalysisForm.test.tsx: o caso "acusa o campo vazio ao tentar gravar" falhou (o texto esperado não apareceu — o campo mostrava só o helperText estático). Restaurada a correção, os 8 casos do arquivo voltaram a passar.

Testes: pytest da API completo — 779 verdes (uma execução anterior, concorrente com a troca de arquivos do red-green, registrou 1 falha em área não tocada, test_partner_agency_search.py, e 1 erro em test_dossie_da_garantia.py — ambos desapareceram na reexecução limpa; ver Estado do ferramental do CLAUDE.md sobre o primeiro ser intermitente conhecido). Suíte do web completa — 115 verdes. tsc --noEmit limpo. ruff nos arquivos tocados — mesma contagem de apontamentos pré-existentes da main (13), nenhum novo.

e2e: depois de /devremote-rebuild (api + web), rodados os três specs que exercitam a tela alterada — credit-analyses-form-steps.spec.ts, credit-analyses.spec.ts, credit-analyses-mobile.spec.ts. Um caso (CA5/CA6/CA7 — cadastro inline de inquilino e imóvel) parou de gravar porque não preenchia a renda do principal — ajustado para preencher o campo antes do envio, com comentário citando o fix. Reexecução: 22 de 22 verdes.

O que diverge do plano original: nada além do item já registrado acima (create() como terceiro ponto de escrita). Escopo, momento e mensagens saíram como decidido na seção 10.