Skip to content

[types] Arquivar tipo de investimento afirma "tipo com saldo" para qualquer falha — e o botão só aparece quando o saldo já é zero, então a causa afirmada é a única impossível #143

Description

@Guiroos

Onde

Dois sites, ambos renderizados (app/(app)/investimentos/page.tsx:159 desktop, :165 mobile):

  • components/investimentos/InvestmentTypeCard.tsx:133-135
  • components/investimentos/InvestmentTypeAccordion.tsx:122-124

Evidência

// components/investimentos/InvestmentTypeCard.tsx:129-137 (idêntico no Accordion, :118-126)
const handleArchive = () => {
  startTransition(async () => {
    try {
      await archiveInvestmentType(balance.id)
    } catch {
      toast.error('Não é possível arquivar tipo com saldo.')
    }
  })
}

catch {} sem binding: a causa real é descartada antes de existir, e o componente preenche a lacuna com um palpite fixo. A causa afirmada tem respaldo numa linha da action:

// lib/actions/investments.ts:108-110
if (Math.round(currentBalance * 100) > 0) {
  throw new Error('Não é possível arquivar tipo com saldo.')
}

O que fecha o achado é que essa é justamente a falha que não pode acontecer quando o handler roda, porque o botão que o dispara só existe no caso complementar:

// components/investimentos/InvestmentTypeCard.tsx:149-163
const archiveAction = balance.archived
  ? { label: 'Restaurar', ... }
  : Math.round(balance.currentBalance * 100) <= 0
    ? { label: 'Arquivar', icon: Archive, onClick: handleArchive, ... }
    : undefined

Math.round(balance.currentBalance * 100) <= 0 é a negação exata do guard da action. O handleArchive do desktop, por construção, só é alcançável quando archiveInvestmentType não pode lançar por saldo. O Accordion é ainda mais restritivo (balance.currentBalance === 0, :145). O mesmo balance.currentBalance alimenta os dois lados: vem de getInvestmentBalances (lib/queries/investments.ts:57), que a page passa para os dois componentes.

Ou seja: toda vez que esse toast aparece, a causa que ele afirma está errada. O que sobra de alcançável é sessão expirada (requireUserId lança em lib/auth/require-user.ts:6), assertOwnsInvestmentType (lib/actions/investments.ts:74), falha de rede e banco indisponível. Nos quatro a ação corretiva é recarregar ou refazer login — e o usuário recebe uma frase sobre saldo de investimento. Sem console.error no caminho e sem Sentry no projeto, esse toast é também a única evidência que a falha deixa, e ela aponta para o lugar errado.

Relação com a #35, que examinou este mesmo par de sites e os deixou fora do escopo — corretamente. A #35 tratava de RowActions/DeleteButton afirmando "item em uso" para exclusão, e registrou em nota que "InvestmentTypeAccordion.tsx:123 e InvestmentTypeCard.tsx:134 repetem a forma [...] ali a causa afirmada tem respaldo em lib/actions/investments.ts:109, mas continua sendo aplicada a falhas de rede e sessão também". A seção Onde da #35 nunca incluiu archiveInvestmentType, e a Proposta dela também não — o fechamento foi legítimo. O que é novo aqui e o que muda a conclusão daquela nota: o respaldo citado não existe na prática, porque o gate do render exclui o caso. Não é "mensagem certa aplicada larga demais"; é mensagem que nunca está certa. Precedente do mesmo formato neste repo: a #122, aberta para o resto que a #34 registrou em nota e não fechou.

A correção da #35 já está mergeada e é o padrão a seguir (components/ui/row-actions.tsx:56-59, components/ui/delete-button.tsx:40-43): catch (err) com console.error e mensagem genérica, com a causa específica como prop de quem a conhece.

Verificações feitas (tentativa de falsificar o achado)

  1. O gate realmente exclui a causa? Sim, e conferi os dois lados no mesmo dado. balance é InvestmentBalance (lib/queries/investments.ts:93), e currentBalance na :57 é totalAmount + totalYield - totalWithdrawn; a action recalcula a mesma coisa em :94-107 a partir das mesmas tabelas. O único vão é uma corrida (aporte registrado em outra aba entre o render e o clique), que revalidatePath('/investimentos') fecha em toda mutação normal — é a exceção, não a regra, e mesmo nela a mensagem só acertaria por acidente.
  2. Foi decisão deliberada? Não. git log -S "Não é possível arquivar tipo com saldo" -- components/ aponta para um único ponto de merge (98c85f4), o mesmo que introduziu os dois gates — a string nunca foi revisitada depois que o gate passou a existir. Não há commit que discuta contrato de erro nesses arquivos.
  3. É a convenção do repo? Não, é a minoria. No recorte que importa — catch de handler de mutação em components/ — a forma dominante é mensagem genérica: 'Erro ao restaurar.' nos mesmos dois arquivos, duas funções abaixo (InvestmentTypeCard.tsx:143-145, InvestmentTypeAccordion.tsx:132-134), mais 'Erro ao salvar.', 'Erro ao registrar resgate.', 'Erro ao enviar feedback.' e ~20 outras. Depois do merge da [types] RowActions afirma "item em uso" para qualquer falha de exclusão, mas 7 das 8 actions que ele aciona não têm esse modo de falha #35, grep -rn "catch" components/ app/ --include="*.tsx" deixa exatamente estes dois catch afirmando uma causa concreta de negócio. Não é discussão de arquitetura.
  4. Já é coberto? Não. Não há teste que exercite o caminho de erro desses handlers, e __tests__/integration/actions-investments.test.ts testa a action, não o cliente. Nada quebra hoje se a mensagem estiver errada.
  5. Limitação declarada: ambiente sem node_modules; nada foi executado. Toda a cadeia é lida do fonte com arquivo e linha.

Impacto

Quem abre /investimentos para arquivar um tipo já zerado — o fluxo normal de fim de vida de um CDB, uma reserva resgatada, um tipo criado por engano — e esbarra em sessão expirada (JWT com maxAge de 24 h, lib/auth.ts:43) ou rede instável. Em vez de "tente novamente", recebe "Não é possível arquivar tipo com saldo."

A mensagem é especialmente cara aqui porque é plausível: o usuário acabou de mexer em saldos, e a frase descreve uma regra real do produto. A conclusão natural é que o app ainda enxerga saldo no tipo — então ele vai conferir os aportes, refazer contas, procurar um resgate que faltou. O botão "Arquivar" continua na tela (o gate depende só do saldo, que não mudou), reforçando a leitura de que o problema é o dado, não a sessão. Nada no caminho registra a exceção real.

Vale nos dois pontos de render: card no desktop (lg+) e accordion no mobile.

Achado adjacente, fora do foco de hoje e registrado na #45 para a rotação de sexta: os dois gates divergem — Math.round(balance.currentBalance * 100) <= 0 no Card (:156) contra balance.currentBalance === 0 no Accordion (:145). Como currentBalance é soma de floats e createWithdrawal (lib/actions/investments.ts:195) não valida resgate contra o saldo, saldo negativo ou com resíduo de ponto flutuante é alcançável — e nesse estado o desktop oferece "Arquivar" enquanto o mobile não oferece ação nenhuma. Isso é categoria 5 (.claude/audit.md), não 4, e não está sendo pedido aqui.

Cobertura

Não existe teste que pegue isso, e teste de render não é opção — não há @testing-library/react no projeto, então propor um significa propor a infra inteira.

O caminho é o mesmo gate por string sobre o fonte que a correção da #35 já deixou versionado: __tests__/unit/row-actions.test.ts:18 faz readFileSync + asserção sobre components/ui/row-actions.tsx, e __tests__/unit/service-worker-registro.test.ts usa a mesma maquinaria.

Caso proposto — estender __tests__/unit/row-actions.test.ts (ou um arquivo irmão) com um it() que varre components/**/*.tsx e falha se algum catch de handler de mutação afirmar uma causa de negócio sem console.error no mesmo bloco:

it('catch de mutação não afirma causa sem registrar a exceção real', () => {
  const ofensores = arquivosTsx().filter((f) => {
    const src = readFileSync(f, 'utf-8')
    return /catch\s*\{[^}]*toast\.error\(\s*'Não é possível [^']*'\s*\)/.test(src)
  })
  expect(ofensores).toEqual([])
})

A entrada que só a correção certa rejeita: os dois arquivos como estão hoje. O gate falha agora com exatamente 2 ofensores e passa depois. E descarta as duas correções erradas mais prováveis:

⚠️ O regex precisa ancorar na frase de negócio, não em toast.error genérico: 'Não foi possível excluir. Tente novamente.' (row-actions.tsx:58) é a forma correta já mergeada e não pode virar falso-positivo.

Proposta

Nos dois arquivos, o padrão já ratificado pela #35:

const handleArchive = () => {
  startTransition(async () => {
    try {
      await archiveInvestmentType(balance.id)
    } catch (err) {
      console.error('[InvestmentTypeCard] archiveInvestmentType falhou', err)
      toast.error('Não foi possível arquivar. Tente novamente.')
    }
  })
}

Por que mensagem genérica e não converter archiveInvestmentType para ActionResult (exigência 6): o doc comment de lib/actions/types.ts:1-8 reserva o retorno tipado para "toda falha que o usuário pode causar e precisa entender". Aqui não há nenhuma: o gate do render já impede a única falha de negócio da action, e o que sobra — sessão, rede, ownership — é exatamente a classe excepcional que o mesmo comentário manda manter como throw. Converter a action adicionaria um contrato para um caminho que nunca é percorrido. É o mesmo julgamento que a #122 registrou para updateDebtEntry.

Por que o console.error junto e não só trocar a string: sem ele, o catch vira o "handler que engole a exceção sem log nem feedback" que o CLAUDE.md proíbe — a troca consertaria a mensagem e pioraria o diagnóstico. É o mesmo remédio já aplicado em row-actions.tsx:57 e delete-button.tsx:41.

Manter a frase sobre saldo em algum lugar não é necessário: a regra já é comunicada pela ausência do botão "Arquivar" enquanto houver saldo, que é a forma correta de expressá-la.

Custo estimado

P (1 arquivo por site; 2 arquivos no total, mais 1 de teste se o gate entrar). Os dois catch são idênticos e a mudança é a mesma nos dois — a exigência 8 pede que o PR feche os dois; cobrir só um exige refs #N em vez de closes #N.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions