Skip to content

[types] "Registrar só a minha parte" com as partes cobrindo o gasto inteiro trava o Salvar em silêncio — o erro é roteado para o campo Valor, que mostra um número válido, e a mensagem é descartada no caminho #142

Description

@Guiroos

Onde

  • components/forms/TransactionForm.tsx:138-142 — deriva o valor submetido
  • components/forms/TransactionForm.tsx:281-291 (saída avulsa) e :338-348 (parcelado) — submetem o valor derivado sob a chave amount/totalAmount
  • components/forms/transaction/HeroAmountCard.tsx:119 — recebe o erro e joga a mensagem fora
  • components/forms/transaction/SplitSection.tsx:118-119, 236-251 — o painel que já calcula (e já pinta de vermelho) o estado inválido

Evidência

O formulário de lançamento tem um switch "Registrar só a minha parte" (SplitSection.tsx:228-232). Com ele ligado, o valor que vai para a action deixa de ser o que o usuário digitou e passa a ser uma quantidade derivada:

// components/forms/TransactionForm.tsx:138-142
const totalCents = Math.round(parseFloat(previewAmount || '0') * 100)
const totalSplitCents = splits.reduce((s, e) => s + Math.round(parseFloat(e.amount) * 100), 0)
const yourShareCents = Math.max(0, totalCents - totalSplitCents)
const effectivePreviewAmount =
  splitIntegral && splits.length > 0 ? (yourShareCents / 100).toFixed(2) : previewAmount
// components/forms/TransactionForm.tsx:281-291
const amountToUse =
  splitIntegral && splits.length > 0 ? effectivePreviewAmount : str('amount')
const result = transactionSchema.safeParse({ name: str('name'), amount: amountToUse, ... })
if (!result.success) {
  setErrors(formatZodErrors(result.error))
  return
}

Quando as partes cobrem o gasto inteiro, yourShareCents é 0 e amountToUse vira "0.00". transactionSchema.amount é positiveAmountSchema (lib/validations/utils.ts:14-20, exige > 0), então o parse falha. A partir daí a falha some por três seams encadeados:

  1. Não há toast nem log — o ramo é setErrors(...); return, sem nenhuma outra saída.
  2. formatZodErrors chaveia por issue.path[0] (lib/validations/utils.ts:3-10), então a chave é 'amount' — o nome do campo do schema, que aqui não corresponde a nenhum input que o usuário possa editar para resolver.
  3. A mensagem é convertida em booleano e descartada:
// components/forms/transaction/HeroAmountCard.tsx:119
error={!!(errors.amount ?? errors.totalAmount)}

NumericInput usa esse booleano apenas para aplicar inputErrorCls (components/ui/numeric-input.tsx:82) — a string 'Valor deve ser maior que zero' nunca é renderizada. E a borda vermelha cai sobre o input do hero, que exibe previewAmount, ou seja, o valor que o usuário digitou (ex.: 120,00) — não o "0.00" que foi rejeitado.

O painel de divisão já sabe que o estado é inválido: SplitSection.tsx:119 calcula a mesma quantidade sem o clamp e :243 a pinta de text-negative quando é negativa. Essa informação existe na tela e não é usada para nada.

Repro (só UI, sem request forjado): gasto de R$ 120 → "Dividir com alguém" → escolher a pessoa → digitar 120,00 no campo Valor da linha (isso já troca o modo para custom, SplitSection.tsx:86-89) → ligar "Registrar só a minha parte". O painel passa a exibir "Valor a registrar — R$ 0,00". Clicar em Salvar: o campo de valor fica vermelho, nada mais acontece, o formulário continua aberto.

O mesmo dead-end acontece por erro de digitação em modo custom — partes somando mais que o total dão yourShareCents negativo, que Math.max(0, ...) na :140 converte em "0.00" antes do parse.

O caminho parcelado (:338-348) é idêntico, com installmentSchema.totalAmount.

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

  1. É alcançável pela UI, ou só por request forjado? Alcançável pela UI. O clamp da :140 e o mode='custom' da SplitSection.tsx:78-90 são acionados por digitação normal; selectSubmittableSplits (lib/utils/split.ts) não impõe teto de soma, só amountCents > 0. Não é o caso "usuário forjando payload contra os próprios dados" que já matou outros candidatos.
  2. Foi decisão deliberada? Não. git log -S sobre o trecho aponta só para 9b2432c (feat da divisão) e 31486c2 (fix). Nenhuma das duas mensagens menciona parte zero, negativa ou soma acima do total. Vale notar que 31486c2 já corrigiu uma divergência nesse mesmo seam ("soma no painel de divisão o mesmo conjunto que é submetido") — o seam produziu bug antes.
  3. A ausência de mensagem é a convenção do repo? Não, é a exceção. Os campos com erro deste formulário passam por <Field error={errors.X}>, e components/ui/field.tsx:42-46 renderiza a string. São 12 sites assim (TransactionForm 4, SaidaConditionalFields 4, ResgateFields 2, EntradaFields 1, InvestimentoFields 1) contra 3 no HeroAmountCard, todos com !!. Registro isso como suporte, não como argumento principal: a falha se sustenta sozinha — existe uma mensagem escrita que nunca é exibida.
  4. Já é coberto indiretamente? Não. __tests__/unit/split.test.ts cobre computeEqualShare, resolveSplitAmounts e selectSubmittableSplits — e nenhuma delas calcula "sua parte". grep -rn "yourShare" lib __tests__ devolve zero: a derivação existe inline, duplicada em SplitSection.tsx:119 e TransactionForm.tsx:140, com clamps diferentes. Nenhum teste pode alcançá-la hoje.
  5. Limitação declarada: este ambiente está sem node_modules, então o formulário não foi executado. A cadeia acima é lida do fonte, e cada elo tem arquivo e linha — o único passo não observado em runtime é o render da borda vermelha.

Impacto

Quem usa /registro ou o + do bottom nav (components/providers/RegistrationDialog.tsx:68, app/(app)/registro/RegistroPageClient.tsx:238 — ambas renderizadas) para registrar um gasto que é integralmente de outra pessoa: pagar a conta de um familiar, adiantar uma despesa reembolsável, comprar algo para um amigo. É exatamente o caso-limite para o qual o switch "Registrar só a minha parte" existe.

O que o usuário vê: o painel confirma "Valor a registrar — R$ 0,00", ele clica em Salvar e nada acontece além de o campo de valor ficar vermelho — um campo que está exibindo R$ 120,00, um número perfeitamente válido. Não há toast, não há texto de erro, não há console.error, e nenhuma requisição sai. Não existe nenhum elo visível entre a borda vermelha e o painel de divisão logo abaixo, então a leitura natural é "o botão está quebrado". A saída é desistir da divisão ou desligar o switch e registrar o gasto inteiro como seu — que é o dado errado.

O mesmo vale para o erro de digitação (uma casa a mais no valor de uma parte), que é mais frequente que o caso-limite legítimo.

Cobertura

Nenhum teste pega isso hoje, e teste de render não é opção — não há @testing-library/react no projeto.

O caminho é extrair a decisão para lib/utils/split.ts e testá-la como função pura. Caso proposto em __tests__/unit/split.test.ts:

describe('integralSubmitBlockReason', () => {
  it('bloqueia quando as partes cobrem exatamente o total', () => {
    expect(integralSubmitBlockReason(12000, [share('p1', 12000)])).not.toBeNull()
  })
  it('bloqueia quando as partes excedem o total', () => {
    expect(integralSubmitBlockReason(12000, [share('p1', 13000)])).not.toBeNull()
  })
  it('não bloqueia quando sobra parte sua', () => {
    expect(integralSubmitBlockReason(12000, [share('p1', 6000)])).toBeNull()
  })
})

A entrada que só a correção certa aceita é a de cobertura exata (12000 / 12000). Ela descarta as duas correções erradas mais prováveis:

  • trocar positiveAmountSchema por nonNegativeAmountSchema no transactionSchema — passaria a gravar uma transação de R$ 0,00 (poluindo dashboard e /historico com uma linha sem valor), e deixaria integralSubmitBlockReason devolvendo null no caso de cobertura exata: teste vermelho. Também não resolve o caso negativo, que o clamp da :140 já esconde;
  • só passar a mensagem adiante no HeroAmountCard (trocar !!errors.amount por <Field error={errors.amount}>) — mostraria "Valor deve ser maior que zero" sob um campo exibindo R$ 120,00, ou seja, uma afirmação falsa sobre o valor visível. Continua sem tocar na função, teste vermelho.

Não existe teste hoje sobre a função defeituosa — ela não é uma função. Não há, portanto, asserção que afirme o bug e que a correção vá deixar vermelha.

Proposta

  1. Extrair a derivação para lib/utils/split.ts, que é onde ela deveria estar desde o começo:
/** Sua parte, sem clamp: valor negativo é estado inválido e precisa continuar visível. */
export function computeYourShareCents(totalCents: number, entries: SplitEntry[]): number {
  return totalCents - selectSubmittableSplits(entries).reduce((s, e) => s + e.amountCents, 0)
}

/** Motivo pelo qual "Registrar só a minha parte" não pode ser submetido, ou null. */
export function integralSubmitBlockReason(totalCents: number, entries: SplitEntry[]): string | null {
  const share = computeYourShareCents(totalCents, entries)
  if (share > 0) return null
  return share === 0
    ? 'As partes cobrem o gasto inteiro — sua parte é R$ 0,00. Desligue "Registrar só a minha parte" ou reduza alguma parte.'
    : 'As partes somam mais que o valor do gasto. Ajuste os valores.'
}
  1. SplitSection passa a usar computeYourShareCents (removendo a cópia da :119) e exibe o integralSubmitBlockReason dentro do próprio painel, ao lado do "Valor a registrar" que já está lá — é onde o usuário pode agir.

  2. TransactionForm passa a usar as mesmas funções (removendo a cópia da :140, com o Math.max(0, ...)) e retorna cedo quando há motivo de bloqueio, sem chamar safeParse com um valor derivado — assim errors.amount nunca é setado por este caminho.

Por que lib/utils/split.ts e não um helper novo ou lógica inline (exigência 6): o docstring de selectSubmittableSplits (lib/utils/split.ts:39-45) registra literalmente que "duplicar o predicado inline já produziu divergência entre o 'Sua parte' exibido e o valor registrado". yourShareCents é o próximo valor derivado do mesmo seam e está inline em dois arquivos com clamps diferentes — SplitSection.tsx:119 sem clamp, TransactionForm.tsx:140 com Math.max(0, ...). Colocá-lo no mesmo módulo é o que faz os dois concordarem e é a única forma de testar o caso sem instalar infra de render.

Não resolver mexendo no schema (nonNegativeAmountSchema): o problema não é o schema estar rigoroso demais, é o formulário submeter um valor que o usuário não digitou e não pode corrigir no campo onde o erro aparece. E não basta rotear a mensagem para o hero: ela seria correta sobre o valor rejeitado e falsa sobre o valor exibido.

Custo estimado

M (2-4 arquivos): lib/utils/split.ts, components/forms/transaction/SplitSection.tsx, components/forms/TransactionForm.tsx, mais os casos em __tests__/unit/split.test.ts. lib/utils/split.ts já tem entrada em thresholds.perFile do vitest.config.ts — conferir se o piso precisa subir.

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