Pular para conteúdo

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:

{"error":{"code":"INTERNAL_ERROR","message":"Ocorreu um erro inesperado em nosso sistema…"}}

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:

  1. POST /api/v1/credit-analyses/{id}/decision/pend com {"reason": "<qualquer texto com mais de 100 caracteres>"}
  2. → 500. Com 100 caracteres ou menos, → 200.

B:

  1. Criar uma análise (fica em DRAFT)
  2. Abrir /app/credit-analyses/{id} com o console aberto
  3. → uma linha de 404 por carregamento. Idem em SUBMITTED.

C:

  1. Abrir qualquer análise cuja imobiliária tenha CNPJ
  2. → Imobiliária parceira · CNPJ 64840241000121

D: leitura de apps/api/app/services/credit_analysis_service.py:61-78 contra decision_service.py.

E:

  1. Abrir qualquer análise de crédito
  2. Olhar a divisão entre a última linha de "Situação financeira" (Vigência) e o cabeçalho "RESUMO FINANCEIRO"
  3. → filete preto. Verificável sem olho: getComputedStyle(painelEmbutido).borderTopColor devolve rgb(26, 26, 46), quando o token divider é #ADB8C8 (claro) / #49586C (escuro).

  4. Ambiente: reproduzido no dev-remote (dev.garantora.com.br), commit 707416c.

  5. 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.
  6. 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 grava IN_ANALYSIS é decision_service.create(), que exige SUBMITTED.
  • PENDING_DOCS → APPROVED falta na tabela, e é o que acontece toda vez que uma análise volta da pendência com o documento entregue — o approve só 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.reason ganha max_length=500; Decision.decision_reason passa de String(100) para String(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] perde IN_ANALYSIS e ganha APPROVED.
  • decision_service deixa de gravar o status direto e passa pelo _transition do CreditAnalysisService, 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_reason e do módulo credit_analysis refletindo o novo limite e a tabela corrigida. Swagger: a description do reason menciona o limite.

apps/web

  • B · show.tsx só 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.ts ganha formatCnpj, irmão do formatCpf já existente, e um formatDocumento que escolhe pelo comprimento — porque quase todo ponto de exibição mostra "CPF ou CNPJ" conforme a natureza da pessoa. Aplicado em agencies/show.tsx, credit-analyses/show.tsx (inquilino e imobiliária), renters/show.tsx, renters/index.tsx e renters/form.tsx.

  • E · FinancialSummaryPanel: declarar a cor depois do shorthand, ou — melhor — não usar shorthand para uma borda só. A correção usa borderTopColor explícito, que não pode ser desfeito por ordem de declaração, e o mesmo token divider de 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 DRAFT nã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/pend com 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 reason no details — não 500.
  • [x] CA3. O mesmo vale para reject e approve, que compartilham o campo.
  • [x] CA4. Abrir uma análise em DRAFT ou SUBMITTED nã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-21 na 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.py seguem 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ão rgb(26, 26, 46) — em 1440px e 375px, e a divisão continua visível.
  • [x] CA13. A jornada /journeys analise-credito reporta zero defeitos.
  • [x] CA14. make test passa; make lint sem apontamento novo nos arquivos tocados.

8. Plano

  • [x] Reproduzir A com teste que falha (tests/) — motivo de 143 caracteres
  • [x] apps/api — migration String(100) → String(500) em decision_reason
  • [x] apps/api — max_length=500 no reason do schema + Swagger
  • [x] apps/api — corrigir ALLOWED_TRANSITIONS[PENDING_DOCS]
  • [x] apps/api — decision_service passa a transicionar pelo _transition
  • [x] apps/api — docstrings de decision.py e credit_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 / formatDocumento e os seis pontos de exibição
  • [x] apps/web — borderTopColor explícito no painel embutido, e a mesma higiene no cabeçalho interno
  • [x] apps/e2e — regressão do CA4
  • [x] make test e make lint sem apontamento novo
  • [x] Validar no browser em desktop e mobile, contra o dev-remote reconstruído
  • [x] Rodar /journeys analise-credito e 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 para Text sem 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_service validar 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 fix ainda 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_service nã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 por test_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 → APPROVED não estava lá, e ativar a validação primeiro teria recusado o fluxo principal do produto. O teste test_aprovar_direto_da_pendencia_continua_funcionando guarda essa ordem.

  • O reloadAll do web perdeu uma chamada. Com a busca do parecer dependendo do status, chamar loadDecision() ali usaria o status anterior ao recarregamento — logo depois de "Iniciar análise", buscaria com SUBMITTED e 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 import multilinha do show.tsx e 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 no CLAUDE.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. tsc limpo.
  • 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, e scrollWidth - clientWidth == 0.
  • A jornada /journeys analise-credito: 7 passos, 0 defeitos — eram 4.