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¶
- Autenticado como
ADMIN_TENANT,OPERATORouAGENCY_USER— os três papéis comcredit_analyses:createno seed de RBAC (apps/api/app/db/rbac_seed.py:169,230,277); oANALYSTnã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. - Preencher os dados do inquilino principal e o Pacote Locatício, deixando "Renda comprovada (R$)" em branco.
- Salvar — a API responde
201, sem qualquer aviso. -
Chamar
POST /credit-analyses/{id}/submit— a API só barra com422 LESSEE_NO_INCOMEse nenhum LESSEE da análise tiver renda; com um segundo LESSEE que tenha renda preenchida, osubmitpassa mesmo com o principal em branco. -
Ambiente: local / dev-remote — reproduzível em qualquer um, é regra de validação, não depende de dado de produção.
- 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 devalidate()). - Dados/pré-condições: qualquer tenant; papel com
credit_analyses:create(ADMIN_TENANT,OPERATORouAGENCY_USER— nãoANALYST, que só temupdate/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
submite é 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, ...): defaultNone, sem exigência mesmo quandorole == LESSEEeis_primary == True.apps/api/app/services/credit_analysis_participant_service.py—add()(linha 104) eupdate()(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 emsubmit()e usaany(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— colunanullable=True, coerente com o schema acima.apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx:1710-1723—TextFielddo campo "Renda comprovada (R$)" semrequirede semerror/mensagem ligada a estado de validação (diferente do campo vizinho "Aluguel mensal (R$)", que temrequiredna linha 1638-1639).-
apps/web/src/pages/credit-analyses/CreditAnalysisForm.tsx:655-701— funçãovalidate()do formulário: validarental(linha 693) emonths(linha 694), mas não existe entrada equivalente paraincome. -
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 dosubmit(), que usaany()em vez de checar especificamente o inquilino principal. Noapps/web, oTextFieldnunca recebeurequirednem entrou na funçãovalidate()quando o formulário foi escrito — por isso não há mensagem de erro nem bloqueio de salvamento; o valor vazio é convertido paranullexplicitamente no envio (CreditAnalysisForm.tsx:794e ocorrências análogas) e viaja assim até o banco, onde a coluna aceitaNULL. 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 (
ParticipantInputeParticipantUpdatetêm o mesmo padrão), e tanto paraLESSEEquanto paraCO_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 doTextFieldcitados, há pontos de leitura dedeclared_incomeque não precisam mudar —CreditAnalysisForm.tsx:384-396(pré-preenchimento do formulário) eshow.tsx:577,1040,1063(exibição) — ecredit.py:55-85(compute_credit_summary), que apenas toleraNoneao somar. Nenhum deles grava o valor; a única escrita sem validação é a doadd()/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çãoexigir_renda_do_principal),services/credit_analysis_participant_service.py(add()eupdate()) eservices/credit_analysis_service.py(create(), laço de participantes). O schemaschemas/credit_analysis_participant.pynão mudou — o campo continua opcional ali, porque a obrigatoriedade depende derole/is_primary, que o schema não decide.[DIVERGÊNCIA DO PLANO]a seção 4 original não citavacredit_analysis_service.py:create()como terceiro ponto de escrita — apareceu ao implementar: a criação da análise montaCreditAnalysisParticipantdireto, sem passar poradd(), 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)emcredit.py, chamada nos três pontos que decidem ois_primaryfinal 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 emDRAFTsem 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—TextFieldda renda (linhas ~1710-1723) e a funçãovalidate()(linhas 655-705). - O quê: marcar o campo como obrigatório visualmente (
required, mesmo padrão do campo "Aluguel mensal"), adicionar a checagem emvalidate()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 doFIELD_MAPgenérico deloc[-1](que continua excluindodeclared_incomepor RN13d — seção 4) — um tratamento à parte na resposta deadd()/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: oTextFieldjá temdata-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 noCreditAnalysisForm.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
DRAFTsemdeclared_incomeno 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 emDRAFTpode, na teoria, seguir parasubmit()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á toleraNone, 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 embilling_service.pyoudecision_service.py, que não leemdeclared_incomediretamente.- 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/, eapps/web/**/*.test.tsxse o comportamento do formulário pedir) — antes de qualquer correção - [x] Corrigir a causa raiz — na ordem
api → web(sem impacto direto eme2eprevisto — 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 testemake lintsem 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ó nosubmit()— 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.