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:
- Não há toast nem log — o ramo é
setErrors(...); return, sem nenhuma outra saída.
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.
- 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)
- É 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.
- 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.
- 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.
- 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 só inline, duplicada em SplitSection.tsx:119 e TransactionForm.tsx:140, com clamps diferentes. Nenhum teste pode alcançá-la hoje.
- 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
- 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.'
}
-
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.
-
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.
Onde
components/forms/TransactionForm.tsx:138-142— deriva o valor submetidocomponents/forms/TransactionForm.tsx:281-291(saída avulsa) e:338-348(parcelado) — submetem o valor derivado sob a chaveamount/totalAmountcomponents/forms/transaction/HeroAmountCard.tsx:119— recebe o erro e joga a mensagem foracomponents/forms/transaction/SplitSection.tsx:118-119, 236-251— o painel que já calcula (e já pinta de vermelho) o estado inválidoEvidê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:Quando as partes cobrem o gasto inteiro,
yourShareCentsé0eamountToUsevira"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:setErrors(...); return, sem nenhuma outra saída.formatZodErrorschaveia porissue.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.NumericInputusa esse booleano apenas para aplicarinputErrorCls(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 exibepreviewAmount, 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:119calcula a mesma quantidade sem o clamp e:243a pinta detext-negativequando é 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,00no campo Valor da linha (isso já troca o modo paracustom,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
yourShareCentsnegativo, queMath.max(0, ...)na:140converte em"0.00"antes do parse.O caminho
parcelado(:338-348) é idêntico, cominstallmentSchema.totalAmount.Verificações feitas (tentativa de falsificar o achado)
:140e omode='custom'daSplitSection.tsx:78-90sã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.git log -Ssobre o trecho aponta só para9b2432c(feat da divisão) e31486c2(fix). Nenhuma das duas mensagens menciona parte zero, negativa ou soma acima do total. Vale notar que31486c2já corrigiu uma divergência nesse mesmo seam ("soma no painel de divisão o mesmo conjunto que é submetido") — o seam produziu bug antes.<Field error={errors.X}>, ecomponents/ui/field.tsx:42-46renderiza a string. São 12 sites assim (TransactionForm4,SaidaConditionalFields4,ResgateFields2,EntradaFields1,InvestimentoFields1) contra 3 noHeroAmountCard, todos com!!. Registro isso como suporte, não como argumento principal: a falha se sustenta sozinha — existe uma mensagem escrita que nunca é exibida.__tests__/unit/split.test.tscobrecomputeEqualShare,resolveSplitAmountseselectSubmittableSplits— e nenhuma delas calcula "sua parte".grep -rn "yourShare" lib __tests__devolve zero: a derivação existe só inline, duplicada emSplitSection.tsx:119eTransactionForm.tsx:140, com clamps diferentes. Nenhum teste pode alcançá-la hoje.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
/registroou 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/reactno projeto.O caminho é extrair a decisão para
lib/utils/split.tse testá-la como função pura. Caso proposto em__tests__/unit/split.test.ts: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:positiveAmountSchemapornonNegativeAmountSchemanotransactionSchema— passaria a gravar uma transação de R$ 0,00 (poluindo dashboard e/historicocom uma linha sem valor), e deixariaintegralSubmitBlockReasondevolvendonullno caso de cobertura exata: teste vermelho. Também não resolve o caso negativo, que o clamp da:140já esconde;HeroAmountCard(trocar!!errors.amountpor<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
lib/utils/split.ts, que é onde ela deveria estar desde o começo:SplitSectionpassa a usarcomputeYourShareCents(removendo a cópia da:119) e exibe ointegralSubmitBlockReasondentro do próprio painel, ao lado do "Valor a registrar" que já está lá — é onde o usuário pode agir.TransactionFormpassa a usar as mesmas funções (removendo a cópia da:140, com oMath.max(0, ...)) e retorna cedo quando há motivo de bloqueio, sem chamarsafeParsecom um valor derivado — assimerrors.amountnunca é setado por este caminho.Por que
lib/utils/split.tse não um helper novo ou lógica inline (exigência 6): o docstring deselectSubmittableSplits(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:119sem clamp,TransactionForm.tsx:140comMath.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.tsjá tem entrada emthresholds.perFiledovitest.config.ts— conferir se o piso precisa subir.