0006 — Erros encontrados na jornada da análise de crédito¶
| Status | CORRIGIDO |
| Branch | fix/0006-erros-encontrados-pela-jornada |
| Aberto em | 2026-08-08 |
| Severidade | MÉDIA — um erro de servidor em entrada normal; os demais, incômodos |
| Apps afetados | api · web · e2e |
Status:
RASCUNHO→DIAGNOSTICADO→EM CORREÇÃO→CORRIGIDO
Cinco defeitos num registro só. Não é um bug com cinco sintomas: são cinco defeitos independentes, todos pequenos e todos na tela de análise de crédito — quatro achados na mesma execução da jornada da spec 0026 e um (E) relatado pelo usuário depois, somado aqui por ser da mesma tela e do mesmo tamanho. Mantê-los juntos preserva a origem comum e evita cinco branches para mudanças de poucas linhas. Cada um tem sua própria causa raiz na §4.
1. Sintoma¶
A · POST /decision/pend devolve 500 com motivo longo¶
Ao colocar uma análise em pendência com um motivo de mais de 100 caracteres, a API responde HTTP 500:
No log:
sqlalchemy.exc.DBAPIError: <asyncpg.exceptions.StringDataRightTruncationError>:
value too long for type character varying(100)
[SQL: UPDATE decisions SET status=$1, decision_reason=$2 …]
O analista digita um motivo perfeitamente razoável — "Falta o comprovante de renda do inquilino principal. Solicitado à imobiliária em contato telefônico; prazo combinado de 2 dias úteis." (143 caracteres) — e recebe "erro inesperado em nosso sistema".
B · A tela de detalhe registra 404 no console¶
Abrir uma análise em DRAFT ou SUBMITTED produz, no console do browser:
Failed to load resource: the server responded with a status of 404 ()
GET /api/v1/credit-analyses/{id}/decision
Não quebra nada visível — mas polui o console e qualquer monitoramento de erro de front, e é o que a jornada da spec 0026 reporta como os 4 defeitos de analise-credito.
C · Documento aparece sem formatação na tela¶
O painel "Imobiliária parceira" mostra 64840241000121 em vez de 64.840.241/0001-21. O mesmo vale para o documento do inquilino no painel "Inquilino" da análise, na tela do inquilino e na lista de inquilinos: quem cadastra pelo app tem o CPF exibido como 93015078119.
D · A máquina de estados descreve algo que o produto não faz¶
ALLOWED_TRANSITIONS declara PENDING_DOCS → IN_ANALYSIS — transição que nenhum endpoint executa — e omite PENDING_DOCS → APPROVED, que é o caminho normal de uma análise que volta da pendência aprovada. Sem sintoma para o usuário; é a documentação executável do fluxo dizendo o contrário do que o sistema faz.
E · Linha preta separando "Situação financeira" do "Resumo financeiro"¶
Dentro do quadro Situação financeira, a divisão entre a lista de valores e o bloco Resumo financeiro é um filete preto, destoando de todas as outras divisões da página, que são cinza-claro.
O usuário relatou como sendo do mobile. É dos dois. Medido no dev-remote, a cor da borda é
rgb(26, 26, 46)em 1440px e em 375px. No mobile chama mais atenção porque o painel é estreito e o filete cruza a largura toda, mas o defeito é o mesmo — e por isso a correção não é de breakpoint.
2. Reprodução¶
A — com uma análise em IN_ANALYSIS:
POST /api/v1/credit-analyses/{id}/decision/pendcom{"reason": "<qualquer texto com mais de 100 caracteres>"}- → 500. Com 100 caracteres ou menos, → 200.
B:
- Criar uma análise (fica em
DRAFT) - Abrir
/app/credit-analyses/{id}com o console aberto - → uma linha de 404 por carregamento. Idem em
SUBMITTED.
C:
- Abrir qualquer análise cuja imobiliária tenha CNPJ
- →
Imobiliária parceira · CNPJ 64840241000121
D: leitura de apps/api/app/services/credit_analysis_service.py:61-78 contra decision_service.py.
E:
- Abrir qualquer análise de crédito
- Olhar a divisão entre a última linha de "Situação financeira" (Vigência) e o cabeçalho "RESUMO FINANCEIRO"
-
→ filete preto. Verificável sem olho:
getComputedStyle(painelEmbutido).borderTopColordevolvergb(26, 26, 46), quando o tokendivideré#ADB8C8(claro) /#49586C(escuro). -
Ambiente: reproduzido no dev-remote (
dev.garantora.com.br), commit707416c. - Superfície: A e D são de API, independem de tela. B, C e E valem em desktop e mobile — nenhum é de breakpoint, inclusive o E, apesar de ter sido relatado como mobile. PWA não agrava: nenhum dos cinco depende de asset cacheado.
- Dados: qualquer tenant. Encontrados com o papel
gestor(ADMIN_TENANT).
3. Esperado × Atual¶
| Esperado | Atual | |
|---|---|---|
| A | O motivo do analista é aceito; se houver limite, erro de validação claro (422) | 500 INTERNAL_ERROR, sem dizer o que fazer |
| B | Console limpo ao abrir uma análise sem parecer | Uma linha de 404 por carregamento |
| C | 64.840.241/0001-21 e 930.150.781-19 | 64840241000121 e 93015078119 |
| D | A tabela descreve as transições que existem, e vale para todas elas | Declara uma que ninguém faz, omite uma que o produto faz, e não governa as transições da decisão |
| E | Filete divider, como toda divisão da página | rgb(26, 26, 46) — a cor do texto |
4. Causa raiz¶
A · O schema não conhece o limite da coluna¶
Decision.decision_reason é String(100) (app/models/decision.py:145), mas AnalysisDecisionRequest.reason (app/schemas/decision.py:62) é str | None sem max_length. A validação do Pydantic passa, o UPDATE chega ao PostgreSQL e é o banco que recusa — já fora do alcance dos handlers de erro de negócio, virando 500.
O campo nasceu como "código curto da decisão (máx. 100 chars) — usado em comunicações formais" (docstring do model). A interface, porém, o coleta como texto livre multilinha: "Motivo da reprovação (recomendado)" e "Documentos necessários" (show.tsx, DECISION_DIALOG). O produto passou a usar o campo para uma coisa diferente da que ele foi dimensionado, e ninguém reviu o tamanho.
Vale para os três endpoints que gravam o campo: pend, reject e approve.
B · A página passou a buscar o parecer em todos os estados¶
A spec 0025 centralizou a carga da decisão na página (show.tsx:198), para alimentar de uma fonte só o quadro de política e o painel de parecer. O efeito colateral: o GET passou a acontecer também em DRAFT e SUBMITTED, onde a decisão ainda não existe e a API responde 404 — resposta correta, mas que o browser registra como erro de rede antes de qualquer catch do JS.
O catch trata o caso; o que não dá para tratar em JS é a linha do console, que vem da camada de rede. A única correção real é não fazer a requisição quando se sabe que não há o que buscar.
C · Existe formatador de CPF, mas não de CNPJ — e os pontos de exibição não usam nenhum¶
shared/utils/masks.ts tem uma seção "Formatação para exibição (read-only / tabelas)" com formatCpf e formatPhone. Não há formatCnpj — a máscara CNPJ_MASK existe, mas é do IMask, para o campo de entrada.
E os pontos de exibição não chamam formatador nenhum: agencies/show.tsx:186, credit-analyses/show.tsx:634 e :730, renters/show.tsx:84, renters/index.tsx:48, renters/form.tsx:242 imprimem o valor cru.
O dado é cru no banco por decisão da API: _validate_cnpj (app/schemas/common.py:30-36) recusa CNPJ pontuado, e o formulário de inquilino envia onlyNumbers (renters/form.tsx:74). Ou seja: a normalização na entrada está certa e completa — faltou a contrapartida na saída.
Por que só apareceu agora: o CPF da jornada foi enviado com máscara (a API não valida CPF), então a tela exibiu bonito e escondeu o problema; o CNPJ, recusado com máscara, entrou cru e denunciou os dois.
D · A tabela de transições só governa metade das transições¶
ALLOWED_TRANSITIONS (credit_analysis_service.py:61) é consultada em um lugar: _transition (:352) — que só submit e cancel chamam. As quatro transições dirigidas pela decisão (create, approve, reject, pend) gravam credit_analysis.status direto, em decision_service.py:126, 248, 326, sem consultar a tabela.
Daí as duas divergências, que sobreviveram porque nada as confronta:
PENDING_DOCS → IN_ANALYSISé inalcançável. O único ponto que gravaIN_ANALYSISédecision_service.create(), que exigeSUBMITTED.PENDING_DOCS → APPROVEDfalta na tabela, e é o que acontece toda vez que uma análise volta da pendência com o documento entregue — oapprovesó olha o status da decisão (PENDINGé aceito), nunca o da análise.
Não é bug de runtime: é a máquina de estados sendo documentação em vez de regra. O risco é o de sempre com documentação que não executa — ela envelhece sem avisar, e a próxima pessoa acredita nela.
E · Um border-top sem cor reseta a cor herdada para currentColor¶
Em FinancialSummaryPanel.tsx:141-146 o sx do Paper embutido declara, nesta ordem:
border: embedded ? "none" : "1px solid",
borderColor: "divider", // ← cor definida aqui
borderRadius: embedded ? 0 : 2,
borderTop: embedded ? "1px solid" : undefined, // ← e perdida aqui
border-top é shorthand. Declarado sem cor, ele reseta border-top-color para o valor inicial — currentColor —, desfazendo o border-color declarado acima. Como o currentColor do painel é a cor do texto (#1A1A2E), o filete sai preto.
Não é bug do MUI nem do tema: é a regra de cascata do CSS, e ela morde justamente porque as duas declarações parecem cooperar. Duas linhas abaixo, no cabeçalho interno (:159-160), a mesma dupla aparece na ordem inversa — borderBottom e depois borderColor — e por isso funciona. O contraste entre os dois blocos, no mesmo arquivo, é o que torna o defeito fácil de não ver na revisão.
Introduzido pela spec 0025 (RN9), que fundiu os dois painéis financeiros e criou o modo embedded.
5. Correção¶
apps/api¶
- A · schema + migration.
AnalysisDecisionRequest.reasonganhamax_length=500;Decision.decision_reasonpassa deString(100)paraString(500), com migration Alembic. Erro passa a ser 422 com mensagem de campo, e o teto sobe para caber o que o analista escreve.
Decidido pelo usuário: ampliar e validar. Só validar em 100 daria erro claro mas manteria o analista barrado num campo que a tela apresenta como texto livre; só ampliar sem max_length empurraria o mesmo 500 para 501 caracteres.
- D · tabela corrigida e passando a valer.
ALLOWED_TRANSITIONS[PENDING_DOCS]perdeIN_ANALYSISe ganhaAPPROVED.decision_servicedeixa de gravar o status direto e passa pelo_transitiondoCreditAnalysisService, para a tabela governar as quatro transições da decisão. Isso também unifica o registro no histórico e a auditoria, hoje duplicados nos dois serviços.- Docstrings de
Decision.decision_reasone do módulocredit_analysisrefletindo o novo limite e a tabela corrigida. Swagger: adescriptiondoreasonmenciona o limite.
apps/web¶
- B ·
show.tsxsó busca o parecer quando o status implica que ele existe —IN_ANALYSIS,PENDING_DOCS,APPROVED,REJECTED.
Trade-off assumido: CANCELLED fica de fora. Uma análise cancelada depois de iniciada tem parecer, e deixará de exibir o chip de recomendação no quadro de política. É pouco: o painel de parecer já não é renderizado em CANCELLED (showDecisionPanel), e incluí-lo traria de volta o 404 para toda análise cancelada ainda em rascunho — o caso mais comum.
-
C ·
masks.tsganhaformatCnpj, irmão doformatCpfjá existente, e umformatDocumentoque escolhe pelo comprimento — porque quase todo ponto de exibição mostra "CPF ou CNPJ" conforme a natureza da pessoa. Aplicado emagencies/show.tsx,credit-analyses/show.tsx(inquilino e imobiliária),renters/show.tsx,renters/index.tsxerenters/form.tsx. -
E ·
FinancialSummaryPanel: declarar a cor depois do shorthand, ou — melhor — não usar shorthand para uma borda só. A correção usaborderTopColorexplícito, que não pode ser desfeito por ordem de declaração, e o mesmo tokendividerde todas as outras divisões da página.
Aproveitando: a dupla borderBottom/borderColor do cabeçalho interno (:159-160) funciona por acaso da ordem. Recebe o mesmo tratamento, para o arquivo não guardar uma armadilha esperando a próxima edição inverter as linhas.
data-testid: nenhum novo — nenhum elemento interativo nasce.- Responsividade & PWA: B e C são de dado e de requisição; E é de cor. Nenhuma é de breakpoint — todas valem igual nos dois viewports, inclusive a E, que foi relatada como mobile mas reproduz idêntica no desktop. Sem impacto de service worker.
apps/e2e¶
- Teste de regressão cobrindo B: abrir uma análise em
DRAFTnão produz requisição com status ≥ 400. - E é aferível sem olho e entra no mesmo spec: a borda superior do resumo financeiro tem a cor do
divider, não a do texto — verificado nos dois viewports. - A jornada da spec 0026 vira a verificação de fechamento: zero defeitos no relatório é o sinal de que A, B e C sumiram de fato, no ambiente real.
6. Risco / impacto colateral¶
A — ampliar coluna é operação segura (ALTER TABLE … TYPE varchar(500) não trunca nada, só relaxa). O downgrade da migration pode falhar se houver motivo com mais de 100 caracteres gravado; a migration reverte com truncamento explícito, e isso fica no docstring dela. Sem dado em produção (CLAUDE.md).
D é o de maior risco desta correção. Fazer decision_service passar pelo _transition muda o caminho de quatro transições que hoje funcionam. Duas salvaguardas: a tabela é corrigida antes (senão PENDING_DOCS → APPROVED passaria a ser recusado, quebrando o fluxo principal), e os testes existentes de test_analise_completa.py e test_credit_analyses.py cobrem as quatro transições — se alguma regredir, elas reprovam.
Não toca dinheiro. Nenhum dos quatro passa perto de pricing, LMI, cobrança ou sinistro: são validação de texto, uma requisição a menos, formatação de exibição e a tabela de transições.
C altera o que aparece na tela em vários lugares — mas só a apresentação; o valor armazenado e enviado à API continua o mesmo, e formatCpf já se comporta assim (devolve o original quando o comprimento não bate).
E é o de menor risco: uma propriedade de cor num componente. O cuidado é não trocar o filete por nada — o painel embutido precisa da divisão, que é o que separa a situação financeira do resumo dentro do mesmo quadro. Some a borda e os dois blocos viram um só.
7. Critérios de aceite¶
- [x] CA1.
POST /decision/pendcom motivo de 143 caracteres responde 200, e o motivo aparece íntegro na tela. - [x] CA2. Com motivo acima de 500 caracteres, responde 422 com o campo
reasonnodetails— não 500. - [x] CA3. O mesmo vale para
rejecteapprove, que compartilham o campo. - [x] CA4. Abrir uma análise em
DRAFTouSUBMITTEDnão produz nenhuma requisição com status ≥ 400 nem erro no console. - [x] CA5. A partir de
IN_ANALYSIS, o quadro de política continua exibindo a recomendação automática — a correção do CA4 não tirou o parecer de onde ele existe. - [x] CA6. CNPJ da imobiliária aparece como
64.840.241/0001-21na análise e na tela da imobiliária. - [x] CA7. CPF do inquilino aparece formatado na análise, na tela do inquilino e na lista — inclusive para quem foi cadastrado pelo formulário do app.
- [x] CA8. Documento com comprimento inesperado (incompleto, legado) é exibido como veio, sem quebrar a tela.
- [x] CA9.
ALLOWED_TRANSITIONS[PENDING_DOCS]é[APPROVED, REJECTED, CANCELLED]. - [x] CA10. Aprovar, reprovar e pendenciar continuam funcionando — os testes de
test_analise_completa.pyseguem verdes. - [x] CA11. Uma transição inválida dirigida pela decisão responde 422 (
INVALID_STATUS_TRANSITION), em vez de gravar em silêncio. - [x] CA12. A borda superior do resumo financeiro tem a cor do
divider— nãorgb(26, 26, 46)— em 1440px e 375px, e a divisão continua visível. - [x] CA13. A jornada
/journeys analise-creditoreporta zero defeitos. - [x] CA14.
make testpassa;make lintsem apontamento novo nos arquivos tocados.
8. Plano¶
- [x] Reproduzir A com teste que falha (
tests/) — motivo de 143 caracteres - [x]
apps/api— migrationString(100)→String(500)emdecision_reason - [x]
apps/api—max_length=500noreasondo schema + Swagger - [x]
apps/api— corrigirALLOWED_TRANSITIONS[PENDING_DOCS] - [x]
apps/api—decision_servicepassa a transicionar pelo_transition - [x]
apps/api— docstrings dedecision.pyecredit_analysis.py - [x]
apps/api— testes: motivo longo (200), acima do teto (422), transição inválida - [x]
apps/web— buscar o parecer só nos status que o têm - [x]
apps/web—formatCnpj/formatDocumentoe os seis pontos de exibição - [x]
apps/web—borderTopColorexplícito no painel embutido, e a mesma higiene no cabeçalho interno - [x]
apps/e2e— regressão do CA4 - [x]
make testemake lintsem apontamento novo - [x] Validar no browser em desktop e mobile, contra o dev-remote reconstruído
- [x] Rodar
/journeys analise-creditoe conferir o relatório zerado
9. Decisões em aberto¶
Nenhuma. As três que havia foram decididas pelo usuário em 2026-08-08:
- [x] Motivo longo: ampliar para
String(500)e validar em 500 — erro claro e espaço suficiente. Descartado ampliar paraTextsem limite (deixaria o campo sem teto declarado) e só validar em 100 (manteria o analista barrado). - [x] Máquina de estados: corrigir a tabela e fazer o
decision_servicevalidar contra ela — a tabela deixa de ser comentário e passa a ser regra. - [x] Escopo: os quatro achados da jornada neste registro. O quinto (E), relatado depois, foi somado ao mesmo registro a pedido do usuário — mesma tela, mesmo tamanho, e o
fixainda estava aberto.
10. Registro¶
Corrigido em 2026-08-08. Os cinco defeitos saíram; a jornada que os encontrou reporta zero defeitos.
O que era, o que virou¶
| Era | Virou | |
|---|---|---|
| A | 500 com motivo de 143 caracteres | 200; acima de 500 → 422 com reason em details |
| B | um 404 no console por carregamento em DRAFT/SUBMITTED | nenhuma resposta ≥ 400 ao abrir a tela |
| C | 64840241000121 · 93015078119 | 64.840.241/0001-21 · 930.150.781-19 |
| D | tabela declarando o inexistente e omitindo o real, sem governar a decisão | tabela fiel, e validada nas sete transições |
| E | filete rgb(26,26,46) — a cor do texto | rgb(173,184,200) — o token divider |
A suíte não conseguia reproduzir o A¶
test_pendencia_aceita_motivo_longo passou antes da correção. Não é teste mal escrito: os testes da API rodam em SQLite (conftest.py), e o SQLite ignora o limite de VARCHAR(n) — a truncagem que derruba o PostgreSQL não acontece lá.
Consequência que vale além deste fix: defeito de limite de coluna é invisível para a suíte deste projeto. O que ela cobre é a validação do schema (max_length), que roda em qualquer banco; a largura da coluna teve que ser provada contra o PostgreSQL do dev-remote — 133 caracteres gravados íntegros, 501 recusados com 422, e o information_schema confirmando character_maximum_length = 500.
Divergências do planejado¶
-
decision_servicenão passou a chamar o_transition, como o plano dizia, e sim uma função extraída —ensure_transition_allowed. O método completo re-busca a análise, re-autoriza e emite auditoria própria; reaproveitá-lo duplicaria histórico e auditoria em toda decisão. A função isola exatamente o que precisava ser compartilhado: a regra. Coberto portest_historico_registra_a_transicao_da_decisao_uma_vez, que existe justamente porque duplicar era o erro fácil de cometer aí. -
A ordem importou mais do que o plano dizia. Corrigir a tabela antes de fazê-la valer não era detalhe de estilo:
PENDING_DOCS → APPROVEDnão estava lá, e ativar a validação primeiro teria recusado o fluxo principal do produto. O testetest_aprovar_direto_da_pendencia_continua_funcionandoguarda essa ordem. -
O
reloadAlldo web perdeu uma chamada. Com a busca do parecer dependendo do status, chamarloadDecision()ali usaria o status anterior ao recarregamento — logo depois de "Iniciar análise", buscaria comSUBMITTEDe não traria nada. Quem dispara agora é o efeito que observa o status novo. -
E recebeu uma correção a mais que a pedida. O cabeçalho interno do painel usava a mesma dupla
borderBottom/borderColor, funcionando por acaso da ordem das linhas. Ficou em propriedades longas também — senão o arquivo guarda a armadilha esperando a próxima edição reordenar.
Erros meus no caminho¶
- O helper do teste da API usava CNPJ de 12 dígitos: as sete falhas iniciais eram do helper, não do produto. Só depois de corrigi-lo os dois vermelhos verdadeiros apareceram.
- O spec e2e fixava um CNPJ, que tem unicidade por tenant — passou na primeira execução e deu 409 nas quatro seguintes. Passou a gerar um válido e único por execução.
- O inseridor automático de import cravou uma linha no meio de um
importmultilinha doshow.tsxe quebrou o type check. Corrigido à mão.
Verificação¶
- API: 223 passam. A única falha é
test_partner_agency_status_filter.py::test_alcanca_quem_esta_fora_da_primeira_pagina, o flake já documentado noCLAUDE.md— reproduzido 2 em 3 execuções com o código parado, em arquivo que este fix não toca. - Ruff: 18 apontamentos nos arquivos tocados contra 19 do baseline — nenhum novo, e um a menos.
tsclimpo. - e2e: os 5 do spec novo passam contra o dev-remote reconstruído.
- A, contra PostgreSQL: 133 caracteres → 200 e íntegros; 501 → 422 com
reason; coluna em 500. - E, medido nos dois viewports:
rgb(173,184,200), 1px, escrollWidth - clientWidth == 0. - A jornada
/journeys analise-credito: 7 passos, 0 defeitos — eram 4.